diff --git a/devops/compose/README.md b/devops/compose/README.md index 5a162312b..d1212d6ce 100644 --- a/devops/compose/README.md +++ b/devops/compose/README.md @@ -290,38 +290,26 @@ docker compose -f docker-compose.integrations.yml ps gitea Register the default local-ready integration catalog once the stack is up: -```powershell -powershell -ExecutionPolicy Bypass -File scripts/register-local-integrations.ps1 ` - -Tenant demo-prod +```bash +stella auth login +stella config integrations bootstrap local ``` -The helper creates and verifies the 13 turnkey local providers on a fresh -machine. GitLab server/CI and the GitLab registry remain opt-in because they -require Vault-backed PAT material. The scripted local path is: +The CLI creates and verifies the 13 turnkey local providers on a fresh +machine. GitLab server/CI and the GitLab registry remain opt-in but stay inside +the same first-party product flow: -```powershell -powershell -ExecutionPolicy Bypass -File scripts/bootstrap-local-gitlab-secrets.ps1 ` - -VerifyRegistry - -powershell -ExecutionPolicy Bypass -File scripts/register-local-integrations.ps1 ` - -Tenant demo-prod ` - -IncludeGitLab - -powershell -ExecutionPolicy Bypass -File scripts/register-local-integrations.ps1 ` - -Tenant demo-prod ` - -IncludeGitLab ` - -IncludeGitLabRegistry +```bash +stella config integrations bootstrap local --include-gitlab +stella config integrations bootstrap local --include-gitlab --include-gitlab-registry ``` -Or run the GitLab-backed registration in one step: - -```powershell -powershell -ExecutionPolicy Bypass -File scripts/register-local-integrations.ps1 ` - -Tenant demo-prod ` - -IncludeGitLab ` - -IncludeGitLabRegistry ` - -BootstrapGitLabSecrets -``` +The local bootstrap command mints the owned local GitLab PAT against the +compose fixture, stages the resulting `access-token` and `registry-basic` +entries into the writable Vault target, binds the returned `authref://...` +values to the GitLab integrations, and verifies health. The older PowerShell +scripts remain available as compatibility helpers, but they are no longer the +preferred product path for the Stella-owned local fixture lane. For a repeatable browser-driven proof of the Integrations Hub path itself, run: @@ -387,15 +375,21 @@ Gitea is now bootstrapped by the compose service itself: a fresh `stellaops-gite For UI/CLI-first onboarding, the Integrations Secret Authority API can now stage GitLab-class credentials and return the generated `authref://...` bindings without a manual Vault write. The browser flow uses this inline, and -the CLI exposes the same path through: +the CLI exposes the owned local fixture path through: + +```bash +stella config integrations bootstrap local --include-gitlab +stella config integrations bootstrap local --include-gitlab --include-gitlab-registry +``` + +For production or customer-managed third-party systems, use the lower-level +Secret Authority commands directly: ```bash stella config integrations secrets targets stella config integrations secrets upsert-bundle --bundle gitlab-server --target --path gitlab/server --entry access-token=glpat-... ``` -For GitLab, `scripts/bootstrap-local-gitlab-secrets.ps1` is the preferred local bootstrap path. It reuses a valid `secret/gitlab` secret when possible and otherwise rotates the local `stella-local-integration` PAT, then writes `authref://vault/gitlab#access-token` plus `authref://vault/gitlab#registry-basic` into the dev Vault. When you enable the optional GitLab registry surface (`GITLAB_ENABLE_REGISTRY=true`), register it through the `GitLabContainerRegistry` provider with `authref://vault/gitlab#registry-basic`. The local Docker registry connector now follows the registry's Bearer challenge and exchanges that `username:personal-access-token` secret against `jwt/auth` before retrying catalog and tag probes. - `docker-compose.testing.yml` is a separate infrastructure-test lane. It starts `postgres-test`, `valkey-test`, mocks, and an isolated Gitea profile on different ports; it does not start Consul or GitLab. Use `docker-compose.integrations.yml` only when you need real third-party providers for connector validation. **Backend connector plugins** (8 total, loaded in Integrations service): diff --git a/devops/compose/docker-compose.stella-ops.legacy.yml b/devops/compose/docker-compose.stella-ops.legacy.yml index 72ffb01ce..582de1312 100644 --- a/devops/compose/docker-compose.stella-ops.legacy.yml +++ b/devops/compose/docker-compose.stella-ops.legacy.yml @@ -418,10 +418,10 @@ services: Platform__EnvironmentSettings__RedirectUri: "https://stella-ops.local/auth/callback" Platform__EnvironmentSettings__PostLogoutRedirectUri: "https://stella-ops.local/" Platform__EnvironmentSettings__Scope: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:tenants.write authority:users.read authority:users.write authority:roles.read authority:roles.write authority:clients.read authority:clients.write authority:tokens.read authority:tokens.revoke authority:branding.read authority:branding.write authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:operate orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin" - STELLAOPS_ROUTER_URL: "http://router.stella-ops.local" + STELLAOPS_ROUTER_URL: "http://router.stella-ops.local:8080" STELLAOPS_PLATFORM_URL: "http://platform.stella-ops.local" STELLAOPS_AUTHORITY_URL: "http://authority.stella-ops.local" - STELLAOPS_GATEWAY_URL: "http://router.stella-ops.local" + STELLAOPS_GATEWAY_URL: "http://router.stella-ops.local:8080" STELLAOPS_ATTESTOR_URL: "http://attestor.stella-ops.local" STELLAOPS_EVIDENCELOCKER_URL: "http://evidencelocker.stella-ops.local" STELLAOPS_SCANNER_URL: "http://scanner.stella-ops.local" @@ -516,18 +516,6 @@ services: STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__Type: "standard" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__AssemblyName: "StellaOps.Authority.Plugin.Standard" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__Enabled: "true" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__TenantId: "demo-prod" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapUser__Username: "admin" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapUser__Password: "Admin@Stella2026!" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapUser__Roles__0: "admin" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__ClientId: "stella-ops-ui" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__DisplayName: "Stella Ops Console" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowedGrantTypes: "authorization_code refresh_token" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowedScopes: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:tenants.write authority:users.read authority:users.write authority:roles.read authority:roles.write authority:clients.read authority:clients.write authority:tokens.read authority:tokens.revoke authority:branding.read authority:branding.write authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:operate orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RedirectUris: "https://stella-ops.local/auth/callback https://stella-ops.local/auth/silent-refresh https://127.1.0.1/auth/callback https://127.1.0.1/auth/silent-refresh" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__PostLogoutRedirectUris: "https://stella-ops.local/ https://127.1.0.1/" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RequirePkce: "true" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowPlainTextPkce: "false" STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__ID: "demo-prod" STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__DISPLAYNAME: "Demo Production" STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__STATUS: "active" @@ -1415,8 +1403,9 @@ services: Authority__ResourceServer__Authority: "http://authority.stella-ops.local/" Authority__ResourceServer__RequireHttpsMetadata: "false" Authority__ResourceServer__Audiences__0: "" - Authority__ResourceServer__BypassNetworks__0: "172.19.0.0/16" - Authority__ResourceServer__BypassNetworks__1: "172.20.0.0/16" + Authority__ResourceServer__BypassNetworks__0: "172.16.0.0/12" + Authority__ResourceServer__BypassNetworks__1: "127.0.0.1/32" + Authority__ResourceServer__BypassNetworks__2: "::1/128" TIMELINE_Postgres__Timeline__ConnectionString: *postgres-connection Router__Enabled: "${TIMELINE_SERVICE_ROUTER_ENABLED:-true}" Router__Messaging__ConsumerGroup: "timeline" diff --git a/devops/compose/docker-compose.stella-services.yml b/devops/compose/docker-compose.stella-services.yml index bfa1bdf06..42b675e7b 100644 --- a/devops/compose/docker-compose.stella-services.yml +++ b/devops/compose/docker-compose.stella-services.yml @@ -254,10 +254,10 @@ services: Platform__EnvironmentSettings__RedirectUri: "https://stella-ops.local/auth/callback" Platform__EnvironmentSettings__PostLogoutRedirectUri: "https://stella-ops.local/" Platform__EnvironmentSettings__Scope: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:tenants.write authority:users.read authority:users.write authority:roles.read authority:roles.write authority:clients.read authority:clients.write authority:tokens.read authority:tokens.revoke authority:branding.read authority:branding.write authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:operate orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin" - STELLAOPS_ROUTER_URL: "http://router.stella-ops.local" + STELLAOPS_ROUTER_URL: "http://router.stella-ops.local:8080" STELLAOPS_PLATFORM_URL: "http://platform.stella-ops.local" STELLAOPS_AUTHORITY_URL: "http://authority.stella-ops.local" - STELLAOPS_GATEWAY_URL: "http://router.stella-ops.local" + STELLAOPS_GATEWAY_URL: "http://router.stella-ops.local:8080" STELLAOPS_ATTESTOR_URL: "http://attestor.stella-ops.local" STELLAOPS_EVIDENCELOCKER_URL: "http://evidencelocker.stella-ops.local" STELLAOPS_SCANNER_URL: "http://scanner.stella-ops.local" @@ -347,18 +347,6 @@ services: STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__Type: "standard" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__AssemblyName: "StellaOps.Authority.Plugin.Standard" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__Enabled: "true" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__TenantId: "demo-prod" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapUser__Username: "admin" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapUser__Password: "Admin@Stella2026!" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapUser__Roles__0: "admin" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__ClientId: "stella-ops-ui" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__DisplayName: "Stella Ops Console" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowedGrantTypes: "authorization_code refresh_token" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowedScopes: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:tenants.write authority:users.read authority:users.write authority:roles.read authority:roles.write authority:clients.read authority:clients.write authority:tokens.read authority:tokens.revoke authority:branding.read authority:branding.write authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read orch:operate orch:quota analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate packs.read packs.write packs.run packs.approve registry.admin timeline:read timeline:write trust:read trust:write trust:admin signer:read signer:sign signer:rotate signer:admin" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RedirectUris: "https://stella-ops.local/auth/callback https://stella-ops.local/auth/silent-refresh https://127.1.0.1/auth/callback https://127.1.0.1/auth/silent-refresh" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__PostLogoutRedirectUris: "https://stella-ops.local/ https://127.1.0.1/" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__RequirePkce: "true" - STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__BootstrapClients__0__AllowPlainTextPkce: "false" STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__ID: "demo-prod" STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__DISPLAYNAME: "Demo Production" STELLAOPS_AUTHORITY_AUTHORITY__TENANTS__0__STATUS: "active" @@ -1201,8 +1189,9 @@ services: Authority__ResourceServer__Authority: "http://authority.stella-ops.local/" Authority__ResourceServer__RequireHttpsMetadata: "false" Authority__ResourceServer__Audiences__0: "" - Authority__ResourceServer__BypassNetworks__0: "172.19.0.0/16" - Authority__ResourceServer__BypassNetworks__1: "172.20.0.0/16" + Authority__ResourceServer__BypassNetworks__0: "172.16.0.0/12" + Authority__ResourceServer__BypassNetworks__1: "127.0.0.1/32" + Authority__ResourceServer__BypassNetworks__2: "::1/128" TIMELINE_Postgres__Timeline__ConnectionString: "${STELLAOPS_POSTGRES_CONNECTION}" Router__Enabled: "${TIMELINE_SERVICE_ROUTER_ENABLED:-true}" Router__Messaging__ConsumerGroup: "timeline" diff --git a/devops/compose/setup.bootstrap.local.yaml b/devops/compose/setup.bootstrap.local.yaml new file mode 100644 index 000000000..b7b418b16 --- /dev/null +++ b/devops/compose/setup.bootstrap.local.yaml @@ -0,0 +1,21 @@ +version: "1" + +database: + host: db.stella-ops.local + port: 5432 + database: stellaops_platform + user: stellaops + password: stellaops + +cache: + host: cache.stella-ops.local + port: 6379 + database: 0 + +custom: + users.superuser.username: admin + users.superuser.email: admin@stella-ops.local + users.superuser.password: Admin@Stella2026! + users.superuser.displayName: Stella Admin + authority.provider: standard + crypto.provider: default diff --git a/docs-archived/implplan/SPRINT_20260415_008_FE_ui_truthful_state_cutover_and_todo_wiring.md b/docs-archived/implplan/SPRINT_20260415_008_FE_ui_truthful_state_cutover_and_todo_wiring.md new file mode 100644 index 000000000..23081f826 --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260415_008_FE_ui_truthful_state_cutover_and_todo_wiring.md @@ -0,0 +1,169 @@ +# Sprint 20260415-008 - UI Truthful State Cutover And TODO Wiring + +## Topic & Scope +- Remove remaining mounted fake runtime data from admin and operator UI flows and replace it with real backend clients, truthful browser persistence, or explicit empty/unsupported states. +- Resolve UI TODOs that currently hide placeholder behavior, prioritizing mounted routes first and converting dead or unreachable prototypes into verified cleanup decisions instead of silent simulation. +- Bring checked-feature docs and QA evidence back in sync with actual runtime behavior so verified pages stop depending on mock services or seeded rows. +- Working directory: `src/Web/StellaOps.Web`. +- Allowed cross-module edits when required to expose truthful UI contracts: `src/Integrations/**`, `src/Authority/**`, `src/Policy/**`, `src/ReleaseOrchestrator/**`, `src/Findings/**`, `src/ReachGraph/**`, `src/Doctor/**`, `docs/features/checked/web/**`, `docs/modules/ui/**`, and `docs/api/**`. +- Expected evidence: targeted Angular/Vitest specs, Playwright route checks for mounted pages, selective backend tests when new UI contracts are introduced, and updated sprint/doc execution logs. + +## Dependencies & Concurrency +- Depends on `docs/implplan/SPRINT_20260415_001_DOCS_real_service_cutover_plan.md` for the repo-wide no-fake-live-runtime direction. +- Mounted UI rewires that already have clients (`integration-activity`, `export-center`, `issuer-trust`, `offline-kit`) can proceed in parallel with backend contract investigation for `configuration-pane`, policy conflict previews, and image-security. +- Dead-component cleanup (`release-detail.store`, `graph-side-panels`, `node-diff-table`) must not remove any still-mounted route without confirming router usage first. + +## Documentation Prerequisites +- `src/Web/StellaOps.Web/AGENTS.md` +- `docs/modules/ui/architecture.md` +- `docs/UI_GUIDE.md` +- `docs/features/checked/web/configuration-pane.md` +- `docs/features/checked/web/offline-kit-ui-integration.md` +- `docs/features/checked/web/release-management-ui.md` +- `docs/api/gateway/export-center.md` + +## Delivery Tracker + +### FE-TRUTH-001 - Rewire mounted surfaces that already have real backend clients +Status: DONE +Dependency: none +Owners: Developer / Documentation author / Test Automation +Task description: +- Replace local fake arrays and simulated loaders in `features/integration-hub/integration-activity.component.ts`, `features/evidence-export/export-center.component.ts`, and `features/issuer-trust/components/issuer-*.component.ts` with the existing `AuditLogClient`, `EXPORT_CENTER_API`, and `TrustHttpService` flows. +- Preserve loading, empty, and error states without seeding fallback data. If an audit feed or export stream is unavailable, the UI must show the actual transport state instead of synthetic records. + +Completion criteria: +- [ ] No mounted integration-activity, export-center, or issuer-trust route seeds hardcoded runtime records. +- [ ] Existing clients and endpoints provide the rendered data, or the page shows a truthful empty, error, or unavailable state. +- [ ] Targeted UI tests cover both empty and populated flows for each surface. + +### FE-TRUTH-002 - Remove seeded browser-side fallbacks from topology and offline bundle management +Status: DONE +Dependency: none +Owners: Developer / Test Automation +Task description: +- Delete unused mock topology helpers and comments in `features/topology/environments-command.component.ts` and remove seeded fake bundles from `features/offline-kit/components/bundle-management.component.ts`. +- Keep these routes usable by rendering actual API state or browser-persisted bundle state only. When no topology or bundles exist, show the same truthful empty-state pattern used elsewhere in Web. + +Completion criteria: +- [ ] No topology or offline-kit route populates rows or cards from local fake seed data. +- [ ] Empty-state rendering is explicit and stable when APIs, browser cache, or user-imported bundles have no records. +- [ ] Focused tests prove no fake default data appears on first render. + +### FE-TRUTH-003 - Replace the configuration-pane mock service with real integrations and health wiring +Status: DONE +Dependency: FE-TRUTH-001 +Owners: Developer / Documentation author / Test Automation +Task description: +- Replace `features/configuration-pane/services/configuration-pane-api.service.ts` mock implementations with truthful runtime integrations that compose existing integration CRUD, test, health, and audit/history sources. +- If a pane subsection has no backing contract yet, ship an explicit unsupported or unavailable state and record the missing contract in sprint risks and docs instead of generating fake connector history, health, or export rows. + +Completion criteria: +- [ ] `configuration-pane-api.service.ts` no longer returns hardcoded connector or configuration records in mounted flows. +- [ ] Configuration, health, and audit/history sections are backed by real services or clearly marked unsupported or unavailable. +- [ ] Checked-feature docs and tests stop asserting behavior that only exists in mocks. + +### FE-TRUTH-004 - Remove dead or misleading release and graph mock artifacts +Status: DONE +Dependency: none +Owners: Developer / Documentation author +Task description: +- Delete or quarantine unused mock-only surfaces in `features/releases/state/release-detail.store.ts` and `features/graph/graph-side-panels.component.ts`, and clean misleading mock remnants in `features/release-orchestrator/releases/create-deployment/create-deployment.component.ts`. +- Confirm router usage before removal. If any surface is still mounted, convert it to a truthful-state implementation instead of treating it as dead-code cleanup. + +Completion criteria: +- [x] Unused mock stores and generators are removed from live codepaths or deleted outright after usage verification. +- [x] Mounted release and deployment flows do not describe or depend on local fake state. +- [x] Regression tests or route checks prove cleanup did not remove a reachable operator flow. + +### FE-TRUTH-005 - Make policy conflict previews truthful +Status: DONE +Dependency: none +Owners: Developer / Documentation author / Test Automation +Task description: +- Replace generated preview JSON and rule text in `features/policy-governance/conflict-resolution-wizard.component.ts` with real preview payloads when the API exposes them. +- If the backend only returns metadata, render metadata-only previews and explicit messaging that source content is unavailable, then log the required contract follow-up in sprint risks and docs. + +Completion criteria: +- [x] Conflict-resolution wizard no longer fabricates preview content or rule bodies. +- [x] Preview UI reflects real backend payloads or an explicit unavailable-state message. +- [x] Any missing backend contract is documented with the owning module and follow-up path. + +### FE-TRUTH-006 - Replace image-security placeholder data across summary and sibling tabs +Status: DONE +Dependency: none +Owners: Developer / Documentation author / Test Automation +Task description: +- Resolve the `image-summary-tab` TODO by wiring score, findings, reachability, SBOM, VEX, and evidence surfaces to real clients for the active image scope. +- Treat this as a feature-family cutover rather than a single-file TODO: sibling tabs and shared scope UI must stop seeding fake data in mounted routes. If an API is missing, disable or mark the affected view unavailable instead of inventing content. + +Completion criteria: +- [x] Summary cards and sibling image-security tabs render real API-backed data for the current image or a truthful unsupported or empty state. +- [x] No hardcoded findings, reachability, SBOM, VEX, evidence, or environment badges remain in mounted image-security flows. +- [x] Focused tests cover at least one populated and one empty or error path for the feature family. + +### FE-TRUTH-007 - Resolve the lineage node-diff vulnerability-impact TODO +Status: DONE +Dependency: none +Owners: Developer / Documentation author +Task description: +- Confirm whether `features/lineage/components/node-diff-table/diff-table.component.ts` is intentionally dead per the preservation map or still required by a reachable lineage workflow. +- If dead, delete or quarantine the component and close the TODO by removal. If retained, implement truthful vulnerability-impact retrieval and tests against the real lineage contract. + +Completion criteria: +- [x] The sprint records whether `node-diff-table` is dead or supported. +- [x] The TODO is removed either by deletion or by real data wiring. +- [x] Docs reflect the chosen path if the component remains supported. + +### FE-TRUTH-008 - Refresh checked-feature docs and verification artifacts after the cutover +Status: DONE +Dependency: FE-TRUTH-001, FE-TRUTH-002, FE-TRUTH-003, FE-TRUTH-004, FE-TRUTH-005, FE-TRUTH-006, FE-TRUTH-007 +Owners: Documentation author / QA / Test Automation +Task description: +- Update checked-feature pages, UI architecture notes, and verification evidence to match the post-cutover behavior. +- Re-run targeted behavioral verification for each mounted route touched by this sprint so `VERIFIED` claims are backed by real runtime flows instead of build-only evidence or screenshots of fake data. + +Completion criteria: +- [x] Affected checked-feature docs no longer claim verified behavior that depends on mock services or seeded rows. +- [x] QA evidence includes route-level behavioral checks for mounted pages touched by the sprint. +- [x] Sprint execution log records the proof runs and any remaining blocked contracts. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-04-15 | Sprint created from the mounted UI fake-data punch list and the remaining UI TODO inventory. The work is staged so existing-client rewires land first, the fully mock configuration pane follows, and contract-dependent TODOs only ship with truthful unsupported states when backend payloads are absent. | Project Manager | +| 2026-04-15 | FE-TRUTH-001 completed. Integration activity now renders unified audit plus live integration metadata, export center uses the registered export-center API without seeded profiles or runs, and issuer trust list/detail render `TRUST_API` data with truthful loading and empty states. Targeted Angular specs passed for `integration-activity`, `export-center`, and `issuer-trust`. FE-TRUTH-002 is now in progress. | Developer | +| 2026-04-15 | FE-TRUTH-002 completed. The topology environments command no longer carries the dead mock readiness and layout fallback block, and bundle management now renders only browser-persisted manifests instead of seeded example bundles or asset lists. Targeted Angular specs passed for `offline-kit-ui-integration` and `environments-command`. FE-TRUTH-003 is now in progress. | Developer | +| 2026-04-15 | FE-TRUTH-003 completed. `configuration-pane-api.service.ts` now composes live integrations inventory, health probes, connection tests, updates, deletion, export payloads, and audit-backed history instead of returning seeded connector rows. The checked feature doc was updated to reflect the truthful cutover and marked for recheck because the old `VERIFIED` evidence referenced synthetic `Primary Database` content. Targeted Angular specs passed for the configuration-pane service and mounted page, and the combined touched-page regression set passed across 7 files / 30 tests. | Developer | +| 2026-04-15 | FE-TRUTH-004 completed. Router and codepath verification showed the mounted deployment entry point remains the canonical `/releases/deployments/new` compatibility redirect into `/releases/promotions/create`, while `features/release-orchestrator/releases/create-deployment/create-deployment.component.ts`, `features/releases/state/release-detail.store.ts`, and `features/graph/graph-side-panels.component.ts` were orphaned dead code. Those artifacts and the dead deployment wizard spec were removed, the graph barrel stopped exporting the deleted side panel, graph checked-feature docs were pruned, and targeted Angular specs passed for release routes, mounted release detail pages, and the graph page. | Developer | +| 2026-04-15 | FE-TRUTH-005 completed. Contract review across `policy-governance.models.ts`, `policy-governance.client.ts`, and `GovernanceCompatibilityEndpoints.cs` confirmed the mounted conflict API exposes source identifiers, version, and path metadata only, with no rule-body preview payload. The resolution wizard now renders metadata-only previews plus an explicit unavailable message instead of fabricated `condition` or `action` fields, the checked policy-governance feature doc is marked `RECHECK REQUIRED`, and the focused wizard Vitest spec passed with 3 tests. | Developer | +| 2026-04-15 | FE-TRUTH-006 completed. The mounted `/security/images` route now derives scope from live releases, release components, environments, findings, and SBOM explorer rows instead of seeded repositories, image refs, environment badges, or placeholder tabs. Summary, findings, reachability, SBOM, VEX, and evidence tabs now render release-filtered live data or explicit unavailable states, and the focused image-security Angular suite passed with 8 tests covering populated and empty flows. | Developer | +| 2026-04-15 | FE-TRUTH-007 completed. Runtime reference scans confirmed `features/lineage/components/node-diff-table/diff-table.component.ts` was a dead duplicate with no route, selector, or mounted consumer while the active lineage workspace already uses `features/lineage/components/diff-table/`. The node-diff-table component, duplicate specs, dead checked-feature doc, and preservation-map investigate entry were removed, lineage checked docs were pruned of stale model references, and the remaining lineage Angular suite passed across 5 files / 17 tests. | Developer | +| 2026-04-15 | FE-TRUTH-008 completed. Rebuilt the current Web dist, copied it into the live `compose_console-dist` volume because the gateway was serving stale frontend assets, and reran strict Playwright verification against `tests/e2e/ui-truthful-state-cutover.recheck.spec.ts` with `PLAYWRIGHT_BASE_URL=https://stella-ops.local`. Mounted route rechecks passed for integration activity, export center, offline-kit bundle management, setup topology, configuration pane, policy governance conflicts, and image security; fresh `docs/qa/feature-checks/runs/web/**` artifacts and checked-feature doc updates were added. `issuer-trust-management-ui.md` was downgraded to `RECHECK REQUIRED` because `features/issuer-trust/*` no longer owns a mounted route and the canonical issuer page is served by `features/trust-admin/*`. | Developer | +| 2026-04-16 | Closure cleanup completed. The orphaned `docs/features/checked/web/issuer-trust-management-ui.md` page was retired, canonical issuer-route ownership was consolidated into `trust-scoring-dashboard-ui.md`, and the sprint is ready for archival because no task statuses remain open. | Developer | + +## Decisions & Risks +- Decision: mounted Web UI must follow the `src/Web/StellaOps.Web/AGENTS.md` no-mockups convention. Live routes may use real backend clients, truthful browser persistence, or explicit unavailable states only. +- Decision: `integration-activity`, `export-center`, and `issuer-trust` already have client or contract surfaces in the repo, so they should be treated as UI rewires before any new backend work is considered. +- Risk: `configuration-pane` is the highest-risk mounted fake because the entire API service is synthetic while checked-feature docs currently mark the page verified. +- Decision: configuration-pane now treats the integrations API as the source of truth for mounted registry, vault, and settings-store rows, and it renders database, cache, and telemetry sections as truthful empty states until the backend exposes those contracts. +- Decision: `docs/features/checked/web/configuration-pane.md` returned to `VERIFIED` after the run-008 live replay confirmed the mounted `/setup/configuration-pane` route no longer depends on the removed synthetic `Primary Database` row. +- Decision: `offline-kit` bundle management may persist browser-local state, but it must never seed example bundles on first render. +- Risk: `conflict-resolution-wizard` preview content likely needs a backend contract addition because current models carry metadata only. If no preview payload exists, the truthful fallback is metadata-only UI. +- Decision: FE-TRUTH-005 confirmed the current Policy governance conflict contract exposes metadata only (`id`, `type`, `name`, optional `version`, optional `path`). The wizard now renders that metadata and an explicit unavailable-state notice instead of generating fake rule content. +- Risk: the image-security TODO is broader than `image-summary-tab`; sibling tabs still seed placeholder findings, reachability, SBOM, VEX, evidence, and scope data and must be cut over together to avoid mixed-truth pages. +- Decision: FE-TRUTH-006 treats the mounted `/security/images` route as release-scoped because no image-level handoff or deep-link contract exists in the current Web shell. The scope bar now lists live releases and environments, findings/reachability/VEX are filtered from `/api/v2/security/findings`, SBOM rows come from `/api/v2/security/sbom-explorer`, and the evidence tab is an explicit release-level unavailable state. +- Decision: FE-TRUTH-008 added `docs/features/checked/web/image-security-release-backed-ui.md` plus route-level run-001 evidence so `/security/images` is covered by a dedicated checked-feature page. +- Risk: `release-detail.store`, `graph-side-panels`, and possibly `node-diff-table` appear unmounted or dead. Route reachability must be confirmed before backend effort is spent on them. +- Decision: `create-deployment.component.ts` appears largely API-backed already, so the sprint should remove only remaining misleading mock remnants unless deeper reachable fake state is confirmed during implementation. +- Decision: route verification confirmed `/releases/deployments/new` is a compatibility redirect to `/releases/promotions/create`; the local `create-deployment.component.ts` wizard was unreachable and removed instead of being kept as a second deployment entry flow. +- Decision: `features/releases/state/release-detail.store.ts` and `features/graph/graph-side-panels.component.ts` were unreferenced by mounted routes or components and were deleted as dead mock-bearing artifacts. +- Decision: FE-TRUTH-007 confirmed `features/lineage/components/node-diff-table/` was a dead duplicate preserved only by tests, docs, and the preservation map. The mounted lineage flow already uses `features/lineage/components/diff-table/`, so the node-prefixed variant was deleted instead of wiring another unreachable TODO. +- Decision: every feature doc under `docs/features/checked/web/**` touched by this sprint must be updated before closure so verified status reflects runtime truth rather than design intent. +- Decision: FE-TRUTH-008 replays mounted truthful-state routes against the live `stella-ops.local` gateway, not the dev testbed, because stale compiled assets can otherwise hide whether the checked docs match the actually served UI. +- Decision: the orphaned `issuer-trust-management-ui.md` checked page was retired on 2026-04-16 because canonical issuer management is already owned by the mounted `features/trust-admin/*` workspace and its checked docs. + +## Next Checkpoints +- Land the lowest-risk rewires first: integration activity, export center, issuer trust, topology cleanup, and offline-kit empty-state truthfulness. +- Tackle `configuration-pane` as a dedicated follow-on change so the page can be verified independently after the simpler mounted routes stop using fake state. +- Finish with policy-governance, image-security, dead-component decisions, and the QA/doc refresh once the real contract surface is settled. diff --git a/docs-archived/implplan/SPRINT_20260416_002_Platform_bootstrap_auth_and_integration_onboarding_hardening.md b/docs-archived/implplan/SPRINT_20260416_002_Platform_bootstrap_auth_and_integration_onboarding_hardening.md new file mode 100644 index 000000000..a35fb7ad6 --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260416_002_Platform_bootstrap_auth_and_integration_onboarding_hardening.md @@ -0,0 +1,154 @@ +# Sprint 20260416_002 - Bootstrap, Auth, and Integration Onboarding Hardening + +## Topic & Scope +- Make fresh-install bootstrap actually self-serve: a clean `docker compose` local environment must be completable without hidden API workarounds or missing OAuth client seed data. +- Align CLI, Platform setup sessions, and Authority so the first authenticated action after bootstrap is deterministic and works in a fresh shell. +- Make local-fixture integration onboarding a first-party product flow instead of an operator improvisation exercise. +- Working directory: `src/Platform/`. +- Cross-module edits allowed: `src/Cli/**`, `src/Authority/**`, `devops/compose/**`, `docs/modules/cli/**`, `docs/modules/platform/**`, `docs/modules/authority/**`, `docs/setup/**`, `docs/integrations/**`, `docs/API_CLI_REFERENCE.md`, `docs/INSTALL_GUIDE.md`, `docs/quickstart.md`, `docs/dev/DEV_ENVIRONMENT_SETUP.md`. +- Expected evidence: setup/session API tests, CLI auth and integration regression tests, local compose rebuild transcripts, and updated module docs. + +## Dependencies & Concurrency +- Depends on the archived reset/rebuild stream [SPRINT_20260416_001_Platform_local_compose_reset_rebuild_and_cli_integration_bootstrap.md]() for concrete failure evidence. +- Safe parallelism is moderate: + separate workers can own `src/Platform/**`, `src/Cli/**`, and `src/Authority/**` only when the contract is agreed first. +- Do not treat local compose fixture hacks as final behavior; the product contract must be settled before more bootstrap shortcuts land. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/modules/platform/platform-service.md` +- `docs/modules/cli/architecture.md` +- `docs/modules/cli/guides/setup-guide.md` +- `docs/setup/setup-wizard-ux.md` +- `docs/modules/authority/operations/monitoring.md` + +## Delivery Tracker + +### PLATFORM-BOOT-001 - Make the admin setup step resumable without losing required secrets +Status: DONE +Dependency: none +Owners: Developer / Implementer, Documentation author +Task description: +- The setup session is correct to sanitize `users.superuser.password` from persisted draft state, but the current product still needs that value to survive long enough to complete the admin apply. The fresh-install operator experience must not require a hidden direct API call after the CLI or UI appears to have accepted the password. +- Introduce an explicit secret-handling contract for setup: either an encrypted transient secret store keyed by setup session and step, or an apply-only secret channel with a backend-issued draft token or handle that survives resume but never rehydrates plaintext into normal draft reads. +- CLI and UI must both follow the same contract. The setup session model must clearly separate `draftValues` from secret-bearing step payloads so the operator understands what is persisted and what must be re-entered. + +Completion criteria: +- [x] A clean setup session can complete the `admin` step without any out-of-band API call. +- [x] Resuming an interrupted setup session does not silently drop the admin password required for apply. +- [x] Platform and CLI tests prove the secret is sanitized from normal session reads while still allowing a truthful apply path. + +### PLATFORM-BOOT-002 - Seed a first-party CLI auth client during bootstrap +Status: DONE +Dependency: PLATFORM-BOOT-001 +Owners: Developer / Implementer, Documentation author +Task description: +- A fresh environment must always contain at least one supported CLI login path. The current local Authority seed has only `stella-ops-ui` with `authorization_code` and `refresh_token`, which leaves fresh-shell CLI admin flows dead on arrival. +- Define and provision first-party clients intentionally: + one public CLI client for human interactive login using device-code or PKCE, and one confidential or service-principal path for automation where appropriate. +- The bootstrap contract must state which client IDs exist by default, which grants they support, and which scopes they are allowed to request in local or dev versus production. + +Completion criteria: +- [x] Fresh local rebuilds expose a supported CLI-auth client without manual DB surgery. +- [x] `stella auth login` works in a fresh shell after bootstrap using the seeded first-party client path. +- [x] Authority docs and seed or runtime tests explicitly cover the default CLI client inventory and grants. + +### PLATFORM-BOOT-003 - Make CLI auth behavior match the documented product contract +Status: DONE +Dependency: PLATFORM-BOOT-002 +Owners: Developer / Implementer, Documentation author +Task description: +- Current CLI docs promise device-code-first login behavior, while the live bootstrap evidence still falls back into client or password assumptions and missing `client_id` failures. That mismatch is not acceptable for a world-class operator surface. +- Reconcile docs and implementation so `stella auth login` has one obvious default flow for humans, deterministic fallback behavior, and clean guidance for non-interactive automation. Machine-readable commands must not emit unrelated startup noise into JSON or table payloads. +- Review SM remote crypto probe behavior and logging so optional provider failures do not pollute unrelated command output. + +Completion criteria: +- [x] CLI auth flow and docs describe the same default login path and required configuration. +- [x] JSON-producing CLI commands remain parseable even when optional crypto providers are unavailable. +- [x] Regression coverage exists for fresh-shell auth, token cache reuse, and tenant-scoped authenticated commands. + +### PLATFORM-BOOT-004 - Turn local integration onboarding into a first-party CLI workflow +Status: DONE +Dependency: PLATFORM-BOOT-003 +Owners: Developer / Implementer, Documentation author +Task description: +- "Using just CLI setup all possible integrations" should mean the product provides a supported first-party onboarding workflow for local fixtures, not that operators must manually mint third-party credentials against GitLab or other fixture APIs. +- Define a fixture bootstrap contract for local compose: + deterministic test credentials or Stella-controlled bootstrap endpoints for fixtures that require secrets, plus a manifest-driven CLI command that stages secrets, creates integrations, and runs health tests. +- Be explicit about the boundary: Stella Ops can orchestrate credential staging and connector registration, but it cannot invent credentials for arbitrary external systems in production. Local or dev fixtures are different because Stella owns that test environment. + +Completion criteria: +- [x] Local fixture integrations can be registered end to end through a first-party CLI workflow without direct calls to fixture-native APIs. +- [x] The workflow clearly distinguishes "fixture bootstrap" from real production third-party credential bring-your-own-secret flows. +- [x] Docs and tests cover the supported local-fixture onboarding command surface and resulting health verification. + +### PLATFORM-BOOT-005 - Enforce truthful bootstrap readiness and post-boot health gates +Status: DONE +Dependency: PLATFORM-BOOT-004 +Owners: Developer / Implementer, Test Automation +Task description: +- The product must distinguish between required bootstrap blockers and non-critical outliers. Today the stack is "healthy enough" for some admin paths while several services remain crash-looping or unhealthy, which is acceptable for triage but not as a polished product contract. +- Define which services are mandatory for setup completion, which are optional, and how setup or diagnostics present those states. `docker compose up` plus `stella setup run` should produce a trustworthy readiness summary instead of forcing operators to infer health from raw container status. +- This work should wire platform diagnostics and compose or runtime evidence together so local QA and customer operators get the same truth model. + +Completion criteria: +- [x] Setup and diagnostics clearly report required-versus-optional service readiness. +- [x] Local compose verification fails loudly when a required setup dependency is unhealthy. +- [x] Documentation and automated checks cover the expected health baseline for a fresh local install. + +### PLATFORM-BOOT-006 - Replay the fresh-install flow on rebuilt current images and close any remaining bootstrap regressions +Status: DONE +Dependency: PLATFORM-BOOT-005 +Owners: Developer / Implementer, Test Automation +Task description: +- Archive readiness must be proven against images built from the current repo state, not previously cached `stellaops/*:dev` tags. The replay must rebuild the services touched by this sprint, reset the local compose volumes again, and rerun the end-to-end CLI bootstrap flow on that fresh stack. +- Any replay failure that blocks `stella setup`, `stella auth login`, or `stella config integrations bootstrap local --include-gitlab --include-gitlab-registry` on the rebuilt stack is in scope for this task. The goal is to leave no hidden "works in tests but not in a clean install" gap behind. + +Completion criteria: +- [x] The local compose replay runs against freshly rebuilt current repo images for the services changed by this sprint. +- [x] `stella setup`, `stella auth login`, and full local integration bootstrap complete successfully on that rebuilt stack without hidden API workarounds. +- [x] The sprint execution log records the rebuild/replay evidence and the sprint is archived only after that proof passes. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-04-16 | Sprint created from the completed local reset or rebuild stream after confirming three product-class gaps: admin setup secret loss, missing first-party CLI auth client seed, and non-first-party local integration credential bootstrap. | Project Manager | +| 2026-04-16 | Started PLATFORM-BOOT-001 implementation. Confirmed the current backend sanitizes `users.superuser.password` out of `draftValues` and therefore breaks resumable `admin` apply; implementing a protected setup-secret companion store plus additive session metadata so probe or apply can reuse retained secrets without exposing plaintext in normal reads. | Developer / Implementer | +| 2026-04-16 | Completed PLATFORM-BOOT-001. Added protected setup-secret companion storage plus `secretDrafts` session metadata, server-side secret hydration for probe or apply, finalize cleanup, CLI or UI surface updates, and refreshed docs for the retained-secret contract. | Developer / Implementer | +| 2026-04-16 | Verification for PLATFORM-BOOT-001: `dotnet build src/Platform/StellaOps.Platform.WebService/StellaOps.Platform.WebService.csproj -v minimal` passed; `src/Platform/__Tests/StellaOps.Platform.WebService.Tests/bin/Debug/net10.0/StellaOps.Platform.WebService.Tests.exe -class "StellaOps.Platform.WebService.Tests.SetupEndpointsTests"` passed 8/8; `...PlatformStartupContractTests` passed 2/2; `...PlatformDurableRuntimeTests` passed 1/1; `src/Cli/__Tests/StellaOps.Cli.Tests/bin/Debug/net10.0/StellaOps.Cli.Tests.exe -class "StellaOps.Cli.Tests.Services.BackendOperationsClientTests"` passed 24/24; `npx vitest run src/app/features/setup-wizard/services/setup-wizard-api.service.spec.ts src/app/features/setup-wizard/components/step-content.defaults.spec.ts --config vitest.codex.config.ts` passed 2 files and 14 tests. | Test Automation | +| 2026-04-16 | Started PLATFORM-BOOT-002. Confirmed the standard Authority plugin seeds bootstrap clients from `etc/authority/plugins/standard.yaml`, not the existing compose env overrides; implementing explicit human and automation CLI bootstrap clients plus CLI-side default-client and fresh-shell login behavior. | Developer / Implementer | +| 2026-04-16 | Completed PLATFORM-BOOT-002. Seeded first-party `stellaops-cli` and `stellaops-cli-automation` clients in `etc/authority/plugins/standard.yaml`, removed misleading compose bootstrap-client env overrides, defaulted CLI bootstrap fallback to `stellaops-cli`, and made `stella auth login` choose the human interactive path for that client while preserving explicit env or config overrides and client-credentials for automation clients. | Developer / Implementer | +| 2026-04-16 | Verification for PLATFORM-BOOT-002: `dotnet build src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj -v minimal` passed; `src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/bin/Debug/net10.0/StellaOps.Authority.Plugin.Standard.Tests.exe -noLogo -noColor -class "StellaOps.Authority.Plugin.Standard.Tests.StandardPluginBootstrapperTests" -class "StellaOps.Authority.Plugin.Standard.Tests.StandardPluginOptionsTests"` passed 16/16; `dotnet build src/Cli/StellaOps.Cli/StellaOps.Cli.csproj -v minimal` passed; `dotnet build src/Cli/__Tests/StellaOps.Cli.Tests/StellaOps.Cli.Tests.csproj -v minimal` passed; `src/Cli/__Tests/StellaOps.Cli.Tests/bin/Debug/net10.0/StellaOps.Cli.Tests.exe -noLogo -noColor -class "StellaOps.Cli.Tests.Configuration.CliBootstrapperTests" -class "StellaOps.Cli.Tests.Commands.CommandHandlersTests"` passed 3/3. | Test Automation | +| 2026-04-16 | Started PLATFORM-BOOT-003. Confirmed the live CLI auth handler is already human-password-first for the seeded `stellaops-cli` client, while several CLI docs still describe device-code or browser defaults; also confirmed startup authority or crypto diagnostics currently run before command dispatch and can pollute otherwise machine-readable output. | Developer / Implementer | +| 2026-04-16 | Completed PLATFORM-BOOT-003. Added CLI startup diagnostic gating so optional Authority or crypto warnings emit only for verbose human-readable invocations, updated `auth login` help text and docs to match the seeded human password-bootstrap contract, and refreshed quickstart or dev notes for the SM remote warning surface. | Developer / Implementer | +| 2026-04-16 | Verification for PLATFORM-BOOT-003: `dotnet build src/Cli/StellaOps.Cli/StellaOps.Cli.csproj -v minimal` passed; `dotnet build src/Cli/__Tests/StellaOps.Cli.Tests/StellaOps.Cli.Tests.csproj -v minimal` passed; `src/Cli/__Tests/StellaOps.Cli.Tests/bin/Debug/net10.0/StellaOps.Cli.Tests.exe -noLogo -noColor -class "StellaOps.Cli.Tests.Services.CliStartupDiagnosticsPolicyTests" -class "StellaOps.Cli.Tests.Commands.CommandHandlersTests" -class "StellaOps.Cli.Tests.Commands.RiskBudgetCommandTenantHeaderTests" -class "StellaOps.Cli.Tests.Commands.UnknownsGreyQueueCommandTests"` passed 27/27. | Test Automation | +| 2026-04-16 | Started PLATFORM-BOOT-004. Confirmed the CLI already exposes the required low-level integrations and secret-authority APIs; implementing a manifest-driven `config integrations bootstrap local` flow so the Stella-owned compose fixture lane can create or update entries, stage GitLab secrets into Vault, and verify the resulting catalog without operator-side fixture API calls. | Developer / Implementer | +| 2026-04-16 | Completed PLATFORM-BOOT-004. Added the manifest-driven `stella config integrations bootstrap local` workflow, embedded the owned local compose fixture catalog contract, staged GitLab PAT material through Secret Authority into Vault, bound returned `authref://...` values to the GitLab integrations, and refreshed operator docs so local fixture bootstrap is first-party while production remains BYO-secret. | Developer / Implementer | +| 2026-04-16 | Verification for PLATFORM-BOOT-004: `dotnet build src/Cli/StellaOps.Cli/StellaOps.Cli.csproj -v minimal` passed; `dotnet build src/Cli/__Tests/StellaOps.Cli.Tests/StellaOps.Cli.Tests.csproj -v minimal` passed; `src/Cli/__Tests/StellaOps.Cli.Tests/bin/Debug/net10.0/StellaOps.Cli.Tests.exe -noLogo -noColor -class "StellaOps.Cli.Tests.Commands.IntegrationsCommandGroupTests"` passed 6/6 including the new local bootstrap coverage for default and GitLab-enabled manifests. | Test Automation | +| 2026-04-16 | Started PLATFORM-BOOT-005. Confirmed `PlatformHealthService` and `admin diagnostics health` are still synthetic, while the setup capability contract already distinguishes operational minimums from recommended post-boot surfaces; implementing one backend readiness model for required versus optional dependencies and wiring both setup status and admin diagnostics onto it. | Developer / Implementer | +| 2026-04-16 | Completed PLATFORM-BOOT-005. Replaced synthetic Platform health with a cached readiness contract that distinguishes required setup blockers from optional post-boot services, added `/api/v1/platform/health/readiness`, attached required-only readiness to setup session reads, blocked finalize when required readiness is unhealthy, and wired `stella setup status` plus `stella admin diagnostics health` onto the same backend truth model. | Developer / Implementer | +| 2026-04-16 | Verification for PLATFORM-BOOT-005: `dotnet build src/Platform/StellaOps.Platform.WebService/StellaOps.Platform.WebService.csproj -v minimal` passed; `dotnet build src/Cli/StellaOps.Cli/StellaOps.Cli.csproj -v minimal` passed; `dotnet build src/Platform/__Tests/StellaOps.Platform.WebService.Tests/StellaOps.Platform.WebService.Tests.csproj -v minimal` passed; `dotnet build src/Cli/__Tests/StellaOps.Cli.Tests/StellaOps.Cli.Tests.csproj -v minimal` passed; `src/Platform/__Tests/StellaOps.Platform.WebService.Tests/bin/Debug/net10.0/StellaOps.Platform.WebService.Tests.exe -noLogo -noColor -class "StellaOps.Platform.WebService.Tests.HealthEndpointsTests" -class "StellaOps.Platform.WebService.Tests.SetupEndpointsTests"` passed 11/11; `src/Cli/__Tests/StellaOps.Cli.Tests/bin/Debug/net10.0/StellaOps.Cli.Tests.exe -noLogo -noColor -class "StellaOps.Cli.Tests.Commands.SetupAndAdminReadinessTests"` passed 2/2. | Test Automation | +| 2026-04-16 | Started PLATFORM-BOOT-006 replay closure. A clean-volume replay against the running compose stack showed the CLI setup path works anonymously when pointed at `http://platform.stella-ops.local`, but the stack was still serving stale `stellaops/platform:dev` and `stellaops/authority:dev` images from before this sprint. Fresh PostgreSQL volumes therefore converged only through Platform migration `063` and missed `066_PlatformSetupSessionSecrets.sql`, causing pre-migration secret drafts to disappear before the `admin` step. Rebuilding the sprint-touched images and rerunning the replay from empty volumes is now in progress. | Developer / Implementer | +| 2026-04-16 | Replay debugging for PLATFORM-BOOT-006 surfaced three real regressions in the live fresh-install path: setup endpoints rejected chunked JSON bodies because `ContentLength` was `null`, CLI auth scope overrides could not beat the baked-in default `concelier.jobs.trigger`, and the local GitLab fixture bootstrap hardcoded unsupported PAT scopes for GitLab 17.8.1. Fixed those with targeted changes in `SetupEndpoints.cs`, `CliBootstrapper.cs`, and `LocalIntegrationBootstrapper.cs`, plus focused regression coverage. | Developer / Implementer | +| 2026-04-16 | Completed PLATFORM-BOOT-006. Rebuilt and replayed the fresh-volume local install against current images, confirmed `stella setup` finalized successfully, confirmed fresh-shell `stella auth login --force` requested and cached `integration:read integration:write integration:operate` for tenant `demo-prod`, then reran `stella config integrations bootstrap local --include-gitlab --include-gitlab-registry --format json` to a fully healthy 16-entry catalog after recreating the heavy GitLab fixture with `GITLAB_ENABLE_REGISTRY=true`. | Developer / Implementer | +| 2026-04-16 | Verification for PLATFORM-BOOT-006: `dotnet build src/Cli/StellaOps.Cli/StellaOps.Cli.csproj -v minimal` passed; `src/Cli/__Tests/StellaOps.Cli.Tests/bin/Debug/net10.0/StellaOps.Cli.Tests.exe -noLogo -noColor -class "StellaOps.Cli.Tests.Configuration.CliBootstrapperTests"` passed 3/3; `src/Cli/__Tests/StellaOps.Cli.Tests/bin/Debug/net10.0/StellaOps.Cli.Tests.exe -noLogo -noColor -class "StellaOps.Cli.Tests.Commands.IntegrationsCommandGroupTests"` passed 6/6; live replay succeeded for `stella setup -c devops/compose/setup.bootstrap.local.yaml -y run`, `stella auth login --force`, and `stella config integrations bootstrap local --include-gitlab --include-gitlab-registry --format json` with final `allHealthy: true`. | Test Automation | + +## Decisions & Risks +- Product decision: setup session draft state must remain sanitized, but sanitization is not allowed to break resumable completion of required steps. +- Implemented contract: setup sessions now split sanitized `draftValues` from retained-secret `secretDrafts`; plaintext setup secrets live only in protected companion storage and are deleted on finalize. Docs: [platform-service.md](), [setup-guide.md](), [setup-wizard-ux.md](), [API_CLI_REFERENCE.md](). +- Product decision: a fresh Stella Ops install must always include a first-party supported CLI auth path. Requiring operators to discover or invent `client_id` values is not acceptable. +- Implemented bootstrap contract: local or dev Authority now seeds `stella-ops-ui`, `stellaops-cli`, and `stellaops-cli-automation` through `etc/authority/plugins/standard.yaml`; the CLI defaults to `stellaops-cli` only when no explicit Authority client ID is configured, so env or appsettings overrides still win. +- Product decision: until Authority enables device-code as the real default fresh-shell path, the truthful human CLI login contract is interactive username or password on the seeded `stellaops-cli` client. PKCE redirect URIs are seeded now, but the shipped CLI help and docs now describe the password-bootstrap default explicitly. +- Implemented output contract: startup Authority and crypto diagnostics are opt-in for verbose human-readable invocations and stay suppressed for structured output commands such as `--json`, `--raw`, and `--format json`. Docs: `docs/modules/cli/README.md`, `docs/modules/cli/architecture.md`, `docs/modules/cli/guides/commands/reference.md`, `docs/modules/cli/guides/troubleshooting.md`, `docs/quickstart.md`, `docs/dev/DEV_ENVIRONMENT_SETUP.md`. +- Product decision: local compose fixtures are part of Stella's owned test surface. For that environment, fixture credential bootstrap should be a Stella-managed workflow, not a manual side quest through third-party APIs. +- Implemented local bootstrap contract: `stella config integrations bootstrap local` now loads an embedded local compose manifest, creates or updates the deterministic fixture catalog, stages GitLab credentials through Secret Authority into Vault, and verifies connector test plus runtime health. Operator docs now make that command the primary local path while keeping `secrets targets`, `secrets upsert-bundle`, and explicit `create` or `update` operations as the production BYO-secret workflow. Docs: `docs/modules/cli/guides/setup-guide.md`, `docs/modules/cli/guides/commands/reference.md`, `docs/API_CLI_REFERENCE.md`, `docs/integrations/LOCAL_SERVICES.md`, `docs/INSTALL_GUIDE.md`, `devops/compose/README.md`. +- Replay decision: the fresh-install closure must validate the actual compose fixture contracts, not just CLI mocks. The final replay therefore fixed chunked setup-body handling, CLI authority-scope override precedence, and the local GitLab PAT scope inventory (`api` only on GitLab 17.8.1), then reran the registry variant only after starting the heavy GitLab fixture with `GITLAB_ENABLE_REGISTRY=true`. +- Product decision: setup completion must gate on required control-plane readiness only, while admin diagnostics must still show optional post-boot service degradation instead of hiding it. +- Implemented readiness contract: Platform now exposes `GET /api/v1/platform/health/readiness` as the canonical required-versus-optional dependency model, setup session reads attach the required-only readiness slice, finalize fails when any required dependency is blocked, and `stella admin diagnostics health` surfaces the full readiness view for operators. Docs: [platform-service.md](), [setup-wizard-capabilities.md](), [setup-guide.md](), [admin-reference.md](). +- Residual risk: the Authority architecture still documents device-code or PKCE as the intended long-term human posture; if that implementation lands later, CLI help or docs must be revised again so the seeded password-bootstrap fallback does not become stale in the opposite direction. +- Risk: solving bootstrap or auth cleanly may require explicit schema or seed-data changes in Authority and Platform. Those changes must preserve production safety and not overfit to local compose only. +- Test risk: Microsoft.Testing.Platform still ignores `dotnet test --filter` in this repo (`MTP0001`), so targeted verification for this task used xUnit in-proc class runners instead of suite-wide filter claims. +- Replay risk: `docker compose up` currently pulls `stellaops/*:dev` tags from the local Docker cache, not from the just-tested repo output. Archive proof therefore requires an explicit local image rebuild step before the final fresh-volume replay; otherwise the compose evidence can silently lag the code and migrations that the sprint claims to ship. + +## Next Checkpoints +- Re-run the full local reset or rebuild flow once the hardening tasks land; archive this sprint only after that replay succeeds without workarounds. diff --git a/docs/API_CLI_REFERENCE.md b/docs/API_CLI_REFERENCE.md index 883ac0a17..eda552b20 100755 --- a/docs/API_CLI_REFERENCE.md +++ b/docs/API_CLI_REFERENCE.md @@ -301,7 +301,7 @@ stella setup validate --config | `--dry-run` | `run` only. Probe without applying. | | `--force`, `-f` | `run` starts a fresh session; `reset --all` skips confirmation. | | `--session` | `resume`/`status` only. Use an explicit session id. | -| `--json` | `status` only. Emit machine-readable session state. | +| `--json` | `status` only. Emit machine-readable session state with sanitized `draftValues` and retained-secret `secretDrafts` metadata. | | `--verbose`, `-v` | Enable verbose output. | ### Available Steps @@ -341,6 +341,11 @@ stella setup reset --all --force After `stella setup`, use the authenticated integration/onboarding surfaces instead of more setup steps: ```bash +stella config integrations bootstrap local +stella config integrations bootstrap local --include-gitlab +stella config integrations bootstrap local --include-gitlab --include-gitlab-registry + +# Production or customer-managed systems still use BYO-secret onboarding stella config integrations secrets targets stella config integrations secrets upsert-bundle \ --bundle gitlab-server \ @@ -349,6 +354,51 @@ stella config integrations secrets upsert-bundle \ --entry access-token=glpat-... ``` +Setup-session secret handling: +- Session reads never return plaintext secret values. +- Retained setup secrets surface only as `secretDrafts` metadata and are reused server-side during resume/apply. + +## stella config integrations bootstrap local + +Bootstrap the Stella-owned local compose fixture catalog from the CLI. + +### Synopsis + +```bash +stella config integrations bootstrap local [--include-gitlab] [--include-gitlab-registry] [--format table|json] +``` + +### Options + +| Option | Description | +| --- | --- | +| `--include-gitlab` | Include the owned local GitLab Server and GitLab CI fixtures. The CLI mints and stages the local PAT into Vault automatically. | +| `--include-gitlab-registry` | Include the optional local GitLab container registry fixture. Requires the heavy GitLab compose profile with `GITLAB_ENABLE_REGISTRY=true`. | +| `--format` | Output format: `table` or `json` (default: `table`). | +| `--verbose`, `-v` | Enable verbose output. | + +### Examples + +```bash +# Default 13-entry local fixture catalog +stella config integrations bootstrap local + +# Add GitLab Server and GitLab CI +stella config integrations bootstrap local --include-gitlab + +# Add the optional GitLab registry and emit machine-readable results +stella config integrations bootstrap local --include-gitlab --include-gitlab-registry --format json +``` + +### Contract + +- This command is for Stella-owned local compose fixtures only. +- Default mode creates or updates 13 deterministic local integrations and verifies both connector test and runtime health for each selected entry. +- `--include-gitlab` stages the owned local GitLab PAT through Secret Authority and binds the returned `authref://vault/gitlab#access-token` value to GitLab Server and GitLab CI. +- `--include-gitlab-registry` stages and binds `authref://vault/gitlab#registry-basic` for the optional local GitLab registry surface, which must be started with `GITLAB_ENABLE_REGISTRY=true`. +- Exit code `0` means every selected integration tested healthy and reported healthy runtime status. Exit code `1` means bootstrap or verification failed. +- For production or customer-managed systems, use `stella config integrations secrets targets`, `stella config integrations secrets upsert-bundle`, and explicit `create` or `update` operations with operator-provided credentials. + ## stella advise ask Ask questions to the AdvisoryAI assistant. diff --git a/docs/INSTALL_GUIDE.md b/docs/INSTALL_GUIDE.md index 7e36f9a88..afb4648cf 100755 --- a/docs/INSTALL_GUIDE.md +++ b/docs/INSTALL_GUIDE.md @@ -221,20 +221,21 @@ Verified current UI boundary on `2026-04-14`: `STELLAOPS_BOOTSTRAP_KEY`. - Tenant-scoped onboarding stays on `/setup/*` and other authenticated module surfaces instead of being duplicated inside the bootstrap wizard. -- The inline GitLab path still needs real credential input from the operator. - For repeatable automation, the Playwright harness reads those values from +- The CLI now owns the repeatable local GitLab path, including PAT minting and + Vault staging, through `stella config integrations bootstrap local`. +- The Playwright harness still accepts `STELLAOPS_UI_BOOTSTRAP_GITLAB_ACCESS_TOKEN` and - `STELLAOPS_UI_BOOTSTRAP_GITLAB_REGISTRY_BASIC`. -- `scripts/bootstrap-local-gitlab-secrets.ps1` remains the scripted fallback - when you want to pre-stage the local GitLab authrefs without using the UI. + `STELLAOPS_UI_BOOTSTRAP_GITLAB_REGISTRY_BASIC` when you want the browser + flow to use explicit operator-provided values instead of the CLI-owned local + bootstrap lane. -### Scripted convergence lane +### CLI convergence lane For a fresh local developer install, populate the live integration catalog with: -```powershell -powershell -ExecutionPolicy Bypass -File scripts/register-local-integrations.ps1 ` - -Tenant demo-prod +```bash +stella auth login +stella config integrations bootstrap local ``` This converges the default local-ready lane to 13 healthy providers: @@ -242,37 +243,22 @@ Harbor fixture, Docker Registry, Nexus, GitHub App fixture, Gitea, Jenkins, Vault, Consul, eBPF runtime-host fixture, MinIO, and the three feed mirror providers (`StellaOpsMirror`, `NvdMirror`, `OsvMirror`). -GitLab server/CI and the GitLab registry remain opt-in because they require -Vault-backed credentials. The scripted local path is: +GitLab server/CI and the GitLab registry remain opt-in but stay inside the +same first-party CLI workflow: -```powershell -powershell -ExecutionPolicy Bypass -File scripts/bootstrap-local-gitlab-secrets.ps1 ` - -VerifyRegistry - -powershell -ExecutionPolicy Bypass -File scripts/register-local-integrations.ps1 ` - -Tenant demo-prod ` - -IncludeGitLab - -powershell -ExecutionPolicy Bypass -File scripts/register-local-integrations.ps1 ` - -Tenant demo-prod ` - -IncludeGitLab ` - -IncludeGitLabRegistry +```bash +stella config integrations bootstrap local --include-gitlab +stella config integrations bootstrap local --include-gitlab --include-gitlab-registry ``` -Or run the GitLab-backed registration in one step: - -```powershell -powershell -ExecutionPolicy Bypass -File scripts/register-local-integrations.ps1 ` - -Tenant demo-prod ` - -IncludeGitLab ` - -IncludeGitLabRegistry ` - -BootstrapGitLabSecrets -``` - -`scripts/bootstrap-local-gitlab-secrets.ps1` reuses a valid `secret/gitlab` -secret when possible and otherwise rotates the local `stella-local-integration` -PAT, then writes both `authref://vault/gitlab#access-token` and -`authref://vault/gitlab#registry-basic` into the dev Vault. +The CLI mints the owned local `stella-local-integration` PAT against the +compose fixture, stages both `authref://vault/gitlab#access-token` and +`authref://vault/gitlab#registry-basic` into the writable Vault target, and +then verifies the resulting integrations. Start the heavy GitLab profile with +`GITLAB_ENABLE_REGISTRY=true` before using `--include-gitlab-registry`. The +older PowerShell scripts remain +available for compatibility and debugging, but they are no longer the +preferred product path for the Stella-owned local fixture lane. ## Air-gapped deployments diff --git a/docs/dev/DEV_ENVIRONMENT_SETUP.md b/docs/dev/DEV_ENVIRONMENT_SETUP.md index 1aeeead88..fb1dcfc53 100644 --- a/docs/dev/DEV_ENVIRONMENT_SETUP.md +++ b/docs/dev/DEV_ENVIRONMENT_SETUP.md @@ -62,7 +62,7 @@ dotnet run --project src/Cli/StellaOps.Cli/StellaOps.Cli.csproj -- ` | Output | Class | Meaning | Action | |---|---|---|---| | `GET http://127.1.1.3:8333/` returns `403` | Info | SeaweedFS S3 endpoint is live and rejecting anonymous root requests | Treat `403` as ready for the scratch setup smoke | -| `SM remote service probe failed (localhost:56080)` | Warning | Optional SM remote provider is unavailable | Ignore unless validating China SM remote crypto profile | +| `SM remote service probe failed (localhost:56080)` during `stella --verbose ...` or crypto diagnostics | Warning | Optional SM remote provider is unavailable | Ignore unless validating China SM remote crypto profile; ordinary CLI payload commands now suppress this startup noise | | `stellaops-dev-rekor restarting` without `--profile sigstore` | Warning | Optional Sigstore container from prior run | Ignore for default profile or remove stale container | | `policy ... scheduler_exceptions_tenant_isolation already exists` | Blocking | Outdated Scheduler migration idempotency | Update code and rerun seeding | | `POST /api/v1/admin/seed-demo` returns 500 after patching source | Blocking | Running stale platform container image | Rebuild/restart platform image | diff --git a/docs/features/checked/web/configuration-pane.md b/docs/features/checked/web/configuration-pane.md index 91bda629c..1eafd7c65 100644 --- a/docs/features/checked/web/configuration-pane.md +++ b/docs/features/checked/web/configuration-pane.md @@ -7,11 +7,13 @@ Web VERIFIED ## Description -Console-level configuration pane showing integration status grouped by sections with connection health, detail views per integration, and a state management service for tracking configuration changes. +Console-level configuration pane showing live integration status grouped by sections with connection health, detail views per integration, and signal-based state management. The mounted page now derives rows from the integrations API and unified audit instead of seeded connector records. ## Implementation Details - **Feature directory**: `src/Web/StellaOps.Web/src/app/features/configuration-pane/` - **Routes**: `configuration-pane.routes.ts` +- **Canonical route**: `/setup/configuration-pane` +- **Legacy alias**: `/settings/configuration-pane` - **Components**: - `configuration-pane` (`src/Web/StellaOps.Web/src/app/features/configuration-pane/components/configuration-pane.component.ts`) - `integration-detail` (`src/Web/StellaOps.Web/src/app/features/configuration-pane/components/integration-detail.component.ts`) @@ -22,11 +24,15 @@ Console-level configuration pane showing integration status grouped by sections - **Models**: - `src/Web/StellaOps.Web/src/app/features/configuration-pane/models/configuration-pane.models.ts` - **Source**: Feature matrix scan +- **Current runtime source of truth**: + - Registry, vault, and settings-store rows are composed from the live integrations API. + - History is composed from unified audit integration events. + - Database, cache, and telemetry sections remain explicit empty or missing states until a backing contract exists. ## E2E Test Plan - **Setup**: - [ ] Log in with a user that has appropriate permissions - - [ ] Navigate to `/console/configuration` + - [ ] Navigate to `/setup/configuration-pane` - [ ] Ensure test data exists (scanned artifacts, SBOM data, or seed data as needed) - **Core verification**: - [ ] Verify settings form loads with current values pre-populated @@ -51,19 +57,32 @@ Console-level configuration pane showing integration status grouped by sections - Tier 2 evidence: `docs/qa/feature-checks/runs/web/configuration-pane/run-003/tier2-ui-check.json` - Notes: `/console/configuration` failed strict end-user assertion because the `Configuration` heading never rendered during the Playwright transaction. -## Recheck (run-004) +## Historical Recheck (run-004) - Date (UTC): 2026-02-11 - Status: VERIFIED (strict Tier 2 UI replay) - Tier 1 evidence: Focused configuration-pane suite passed 3/3 across 1 file. - Tier 2 evidence: `docs/qa/feature-checks/runs/web/configuration-pane/run-004/tier2-ui-check.json` - Replay scope: - Navigate to `/settings/configuration-pane` and verify heading, summary metrics, and integration list render. - - Verify `Primary Database` row using scoped `.integration-name` selector. + - Verify the then-current seeded `Primary Database` row using scoped `.integration-name` selector. - Open integration detail panel and verify `Edit Configuration` action is visible. -## Recheck (run-007) +## Historical Recheck (run-007) - Date (UTC): 2026-02-11T10:08:09Z - Status: PASSED (strict Tier 2 UI replay) - Tier 2 evidence: docs/qa/feature-checks/runs/web/configuration-pane/run-007/tier2-ui-check.json -- Notes: Verified on /settings/configuration-pane with configuration summary and detail-panel interaction assertions. +- Notes: Verified on /settings/configuration-pane with configuration summary and detail-panel interaction assertions against the earlier synthetic configuration-pane service. + +## Recheck (run-008) +- Date (UTC): 2026-04-15T17:03:18Z +- Status: VERIFIED (strict Tier 2 UI replay) +- Tier 2 evidence: `docs/qa/feature-checks/runs/web/configuration-pane/run-008/tier2-ui-check.json` +- Replay scope: + - Open `/setup/configuration-pane` and verify the mounted configuration heading and summary copy render from the live integrations-backed page. + - Verify the removed seeded `Primary Database` row does not appear on the mounted route. + +## 2026-04-15 Truthful State Cutover +- `configuration-pane-api.service.ts` no longer seeds connector rows, health checks, or history in mounted flows. +- The route now renders only integrations exposed by the live integrations API and shows explicit empty sections where backend coverage does not exist. +- The mounted page returned to `VERIFIED` after the run-008 live replay against `/setup/configuration-pane`. diff --git a/docs/features/checked/web/image-security-release-backed-ui.md b/docs/features/checked/web/image-security-release-backed-ui.md new file mode 100644 index 000000000..afddac82b --- /dev/null +++ b/docs/features/checked/web/image-security-release-backed-ui.md @@ -0,0 +1,43 @@ +# Image Security Release-Backed UI + +## Module +Web + +## Status +VERIFIED + +## Description +Mounted `/security/images` workspace that derives scope from live releases, release components, environments, findings, and SBOM explorer data. The page now renders truthful empty states when no release is selected and explicit unavailable-state messaging where the current backend contracts expose metadata only. + +## Implementation Details +- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/image-security/` +- **Canonical route**: `/security/images` +- **Components**: + - `image-security-shell` (`src/Web/StellaOps.Web/src/app/features/image-security/image-security-shell.component.ts`) + - `image-summary-tab` (`src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-summary-tab.component.ts`) + - `image-findings-tab` (`src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-findings-tab.component.ts`) + - `image-sbom-tab` (`src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-sbom-tab.component.ts`) + - `image-vex-tab` (`src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-vex-tab.component.ts`) + - `image-evidence-tab` (`src/Web/StellaOps.Web/src/app/features/image-security/tabs/image-evidence-tab.component.ts`) +- **Services**: + - `image-security-data` (`src/Web/StellaOps.Web/src/app/features/image-security/image-security-data.service.ts`) +- **Source**: `docs/implplan/SPRINT_20260415_008_FE_ui_truthful_state_cutover_and_todo_wiring.md` + +## E2E Test Plan +- **Setup**: + - [ ] Log in with a user that has appropriate permissions + - [ ] Navigate to `/security/images` + - [ ] Ensure at least one release exists so the scope selector can populate +- **Core verification**: + - [ ] Verify the empty state teaches the operator to select a release instead of showing fake image data + - [ ] Select a release and verify live release images populate + - [ ] Verify VEX and Evidence tabs show truthful metadata-only copy when deeper contracts are unavailable + +## Verification +- Date (UTC): 2026-04-15T17:03:18Z +- Tier 1 note: focused Angular suite `src/Web/StellaOps.Web/src/tests/image_security/image-security-truthful-state.spec.ts` passed 8/8 during the truthful-state cutover. +- Tier 2 evidence: `docs/qa/feature-checks/runs/web/image-security-release-backed-ui/run-001/tier2-ui-check.json` +- Replay scope: + - Open `/security/images` and verify the mounted empty state renders `No image security scope selected`. + - Select a live release and verify `Release images` renders from real release-scoped data. + - Open `VEX` and `Evidence` tabs and verify the mounted page reports metadata-only or release-level limitations explicitly instead of showing fake tab content. diff --git a/docs/features/checked/web/integration-hub-ui.md b/docs/features/checked/web/integration-hub-ui.md index a2a5d1897..a78b9ce07 100644 --- a/docs/features/checked/web/integration-hub-ui.md +++ b/docs/features/checked/web/integration-hub-ui.md @@ -44,3 +44,11 @@ Integration Hub frontend with list view showing integration status/health, detai - Tier 2 (behavior): pass (`tier2-e2e-check.json`) - Verified on (UTC): 2026-02-11T07:02:25Z +## Recheck (run-002) +- Date (UTC): 2026-04-15T17:03:18Z +- Status: VERIFIED (strict Tier 2 UI replay) +- Tier 2 evidence: `docs/qa/feature-checks/runs/web/integration-hub-ui/run-002/tier2-ui-check.json` +- Replay scope: + - Open `/setup/integrations/activity` and verify the mounted Integration Activity heading and audit-trail copy render. + - Verify the removed `Mock data for development` feed text is absent from the mounted page. + diff --git a/docs/features/checked/web/issuer-trust-management-ui.md b/docs/features/checked/web/issuer-trust-management-ui.md deleted file mode 100644 index a99ce5c15..000000000 --- a/docs/features/checked/web/issuer-trust-management-ui.md +++ /dev/null @@ -1,42 +0,0 @@ -# Issuer Trust Management UI - -## Module -Web - -## Status -VERIFIED - -## Description -Issuer directory trust management UI with issuer list, issuer detail view showing keys and trust bundles, key rotation wizard with confirmation, and issuer lifecycle management under Admin > Trust > Issuers. - -## Implementation Details -- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/issuer-trust/` -- **Routes**: `issuer-trust.routes.ts` -- **Components**: - - `issuer-detail` (`src/Web/StellaOps.Web/src/app/features/issuer-trust/components/issuer-detail.component.ts`) - - `issuer-editor` (`src/Web/StellaOps.Web/src/app/features/issuer-trust/components/issuer-editor.component.ts`) - - `issuer-list` (`src/Web/StellaOps.Web/src/app/features/issuer-trust/components/issuer-list.component.ts`) - - `key-rotation` (`src/Web/StellaOps.Web/src/app/features/issuer-trust/components/key-rotation.component.ts`) - - `issuer-trust` (`src/Web/StellaOps.Web/src/app/features/issuer-trust/issuer-trust.component.ts`) -- **Source**: SPRINT_20251229_024_FE_issuer_trust_ui - -## E2E Test Plan -- **Setup**: - - [ ] Log in with a user that has appropriate permissions - - [ ] Navigate to `/admin/issuers` - - [ ] Ensure test data exists (scanned artifacts, SBOM data, or seed data as needed) -- **Core verification**: - - [ ] Verify the component renders correctly with sample data - - [ ] Verify interactive elements respond to user input - - [ ] Verify data is fetched and displayed from the correct API endpoints -- **Edge cases**: - - [ ] Verify graceful handling when backend API is unavailable (error state) - - [ ] Verify responsive layout at different viewport sizes - - [ ] Verify accessibility (keyboard navigation, screen reader labels, ARIA attributes) - -## Verification -- Run: `docs/qa/feature-checks/runs/web/issuer-trust-management-ui/run-001/` -- Tier 0 (source): pass (`tier0-source-check.json`) -- Tier 1 (build/tests): pass (`tier1-build-check.json`) -- Tier 2 (behavior): pass (`tier2-e2e-check.json`) -- Verified on (UTC): 2026-02-11T07:21:27Z diff --git a/docs/features/checked/web/node-diff-table-component.md b/docs/features/checked/web/node-diff-table-component.md deleted file mode 100644 index 5a0950ce0..000000000 --- a/docs/features/checked/web/node-diff-table-component.md +++ /dev/null @@ -1,43 +0,0 @@ -# Node Diff Table Component (Tabular SBOM Change Comparison) - -## Module -Web - -## Status -VERIFIED - -## Description -Tabular SBOM component-change diff view with change-type filter chips, debounced search, multi-column sorting, row selection with bulk actions, pagination, clipboard actions, and CSV export for lineage comparisons. - -## Implementation Details -- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/lineage/components/node-diff-table/` -- **Primary component**: - - `diff-table.component.ts` - - `diff-table.component.html` - - `diff-table.component.scss` - - `models/diff-table.models.ts` -- **Service dependency**: - - `src/Web/StellaOps.Web/src/app/features/lineage/services/lineage-graph.service.ts` -- **Behavioral tests**: - - `src/Web/StellaOps.Web/src/tests/lineage/node-diff-table-component.spec.ts` - -## E2E Test Plan -- **Setup**: - - [ ] Log in with a user that has appropriate permissions - - [ ] Navigate to lineage comparison workflow hosting the node diff table - - [ ] Ensure comparison data exists (added/removed/changed components) -- **Core verification**: - - [ ] Verify component rows render and sort deterministically - - [ ] Verify search is debounced and filter chips (including both-changed) work - - [ ] Verify bulk actions, pagination, and API-input based diff fetch behavior -- **Edge cases**: - - [ ] Verify graceful handling when backend API is unavailable (error state) - - [ ] Verify responsive layout at different viewport sizes - - [ ] Verify accessibility (keyboard navigation, screen reader labels, ARIA attributes) - -## Verification -- Run: `docs/qa/feature-checks/runs/web/node-diff-table-component/run-001/` -- Tier 0 (source): pass (`tier0-source-check.json`) -- Tier 1 (build/tests): pass (`tier1-build-check.json`) -- Tier 2 (behavior): pass (`tier2-e2e-check.json`) -- Verified on (UTC): `2026-02-11T09:03:08.3546412Z` diff --git a/docs/features/checked/web/offline-kit-ui-integration.md b/docs/features/checked/web/offline-kit-ui-integration.md index af4256083..cdf6327ec 100644 --- a/docs/features/checked/web/offline-kit-ui-integration.md +++ b/docs/features/checked/web/offline-kit-ui-integration.md @@ -42,3 +42,11 @@ Offline Kit UI with OfflineModeService, ManifestValidator, BundleFreshness widge - Tier 1 (build/tests): pass (`tier1-build-check.json`) - Tier 2 (behavior): pass (`tier2-e2e-check.json`) - Verified on (UTC): `2026-02-11T09:23:15.9520926Z` + +## Recheck (run-002) +- Date (UTC): 2026-04-15T17:03:18Z +- Status: VERIFIED (strict Tier 2 UI replay) +- Tier 2 evidence: `docs/qa/feature-checks/runs/web/offline-kit-ui-integration/run-002/tier2-ui-check.json` +- Replay scope: + - Open `/ops/operations/offline-kit/bundles` and verify the mounted bundle-management page renders. + - Verify the removed seeded bundle text (`Mock data - in production, load from IndexedDB or cache`) is absent. diff --git a/docs/features/checked/web/policy-governance-controls-ui.md b/docs/features/checked/web/policy-governance-controls-ui.md index 6f78d11e1..46d34c285 100644 --- a/docs/features/checked/web/policy-governance-controls-ui.md +++ b/docs/features/checked/web/policy-governance-controls-ui.md @@ -9,6 +9,8 @@ VERIFIED ## Description Policy governance controls with risk budget dashboard, trust weighting with impact preview, risk profiles CRUD, sealed mode toggle, and policy conflict dashboard with resolution wizard. +Conflict source previews in the resolution wizard now render source metadata only. The current governance conflicts contract does not expose full rule or policy bodies, so the wizard explicitly reports preview content as unavailable instead of generating synthetic JSON. + ## Implementation Details - **Feature directory**: `src/Web/StellaOps.Web/src/app/features/policy-governance/` - **Routes**: `policy-governance.routes.ts` @@ -48,4 +50,14 @@ Policy governance controls with risk budget dashboard, trust weighting with impa ## Verification - Run: `docs/qa/feature-checks/runs/web/policy-governance-controls-ui/run-001/` - Date (UTC): 2026-02-11 +- Recheck note: the prior verification predates the truthful metadata-only preview cutover in `conflict-resolution-wizard.component.ts`. + +## Recheck (run-002) +- Date (UTC): 2026-04-15T17:03:18Z +- Status: VERIFIED (strict Tier 2 UI replay) +- Tier 2 evidence: `docs/qa/feature-checks/runs/web/policy-governance-controls-ui/run-002/tier2-ui-check.json` +- Replay scope: + - Open `/ops/policy/governance/conflicts` and verify the mounted governance surface renders. + - Open a live conflict resolution flow, advance into the compare step, and verify the wizard reports metadata-only previews instead of fabricated rule bodies. + - Verify the mounted compare step does not render generated `"condition"` or `"action"` JSON fields. diff --git a/docs/features/checked/web/topology-trust-administration-ui.md b/docs/features/checked/web/topology-trust-administration-ui.md index 21df180fe..f0ef91934 100644 --- a/docs/features/checked/web/topology-trust-administration-ui.md +++ b/docs/features/checked/web/topology-trust-administration-ui.md @@ -15,12 +15,12 @@ Follow-up note: canonical `Ops > Platform Setup` leaf URLs are preserved by `doc - **Feature directories**: - `src/Web/StellaOps.Web/src/app/features/topology/` - `src/Web/StellaOps.Web/src/app/features/trust-admin/` - - `src/Web.StellaOps.Web/src/app/features/platform/setup/` - - `src/Web.StellaOps.Web/src/app/features/settings/` + - `src/Web/StellaOps.Web/src/app/features/platform/setup/` + - `src/Web/StellaOps.Web/src/app/features/settings/` - **Primary components**: - - `topology-shell` (`src/Web.StellaOps.Web/src/app/features/topology/topology-shell.component.ts`) - - `trust-admin` (`src/Web.StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts`) - - `platform-setup-home` (`src/Web.StellaOps.Web/src/app/features/platform/setup/platform-setup-home.component.ts`) + - `topology-shell` (`src/Web/StellaOps.Web/src/app/features/topology/topology-shell.component.ts`) + - `trust-admin` (`src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts`) + - `platform-setup-home` (`src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-home.component.ts`) - **Canonical routes**: - `/setup/topology/overview` - `/setup/topology/regions` @@ -66,3 +66,12 @@ Follow-up note: canonical `Ops > Platform Setup` leaf URLs are preserved by `doc - Playwright passed: `1` topology/trust cutover scenario. - Production build passed; existing bundle-budget warnings remain unchanged from the baseline. - Verified on (UTC): 2026-03-08T08:06:30Z + +## Recheck (run-002) +- Date (UTC): 2026-04-15T17:03:18Z +- Status: VERIFIED (strict Tier 2 UI replay) +- Tier 2 evidence: `docs/qa/feature-checks/runs/web/topology-trust-administration-ui/run-002/tier2-ui-check.json` +- Replay scope: + - Open `/setup/topology/overview` and verify the mounted topology page exposes the live `Command` and `Topology` view-mode radios. + - Verify the removed `Mock topology layout for when API returns empty` text is absent. + - Canonical issuer management remains mounted under `features/trust-admin/*`; the orphaned `features/issuer-trust/*` checked page was retired because it no longer owns a live route. diff --git a/docs/features/checked/web/trust-scoring-dashboard-ui.md b/docs/features/checked/web/trust-scoring-dashboard-ui.md index 8a5caec28..9243af979 100644 --- a/docs/features/checked/web/trust-scoring-dashboard-ui.md +++ b/docs/features/checked/web/trust-scoring-dashboard-ui.md @@ -7,11 +7,14 @@ Web VERIFIED ## Description -Trust administration dashboard with signing key management including rotation wizard, issuer trust scores, air-gap audit feed, incident audit, and mTLS certificate inventory. +Trust administration dashboard with signing key management including rotation wizard, issuer trust scores, air-gap audit feed, incident audit, and mTLS certificate inventory. The canonical mounted issuer management route `/setup/trust-signing/issuers` is owned by this trust-admin workspace. ## Implementation Details - **Feature directory**: `src/Web/StellaOps.Web/src/app/features/trust-admin/` - **Routes**: `trust-admin.routes.ts` +- **Canonical mounted routes**: + - `/setup/trust-signing` + - `/setup/trust-signing/issuers` - **Components**: - `airgap-audit` (`src/Web/StellaOps.Web/src/app/features/trust-admin/airgap-audit.component.ts`) - `certificate-inventory` (`src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.ts`) @@ -36,7 +39,7 @@ Trust administration dashboard with signing key management including rotation wi ## E2E Test Plan - **Setup**: - [ ] Log in with a user that has appropriate permissions - - [ ] Navigate to `/admin/trust` + - [ ] Navigate to `/setup/trust-signing` - [ ] Ensure trust dashboard summary fixture/API data is available - **Core verification**: - [ ] Verify the dashboard loads without errors and displays summary cards/metrics @@ -53,3 +56,4 @@ Trust administration dashboard with signing key management including rotation wi - Tier 0: PASS (source/symbol verification for trust-admin routes/component wiring and new behavior harness). - Tier 1: PASS (`npm run test` focused suite: 24 files / 118 tests; `npm run build` passed with known baseline warnings). - Tier 2: PASS (route section coverage, summary/alert metrics behavior, URL-driven active-tab semantics, and refresh error recovery behavior). +- Route ownership note: this checked feature is the active owner for the mounted issuer workflow after the trust-admin cutover. The older `features/issuer-trust/*` checked page was retired when its route family lost all active consumers. diff --git a/docs/integrations/LOCAL_SERVICES.md b/docs/integrations/LOCAL_SERVICES.md index 6fc391a05..9daa8c746 100644 --- a/docs/integrations/LOCAL_SERVICES.md +++ b/docs/integrations/LOCAL_SERVICES.md @@ -110,11 +110,14 @@ node src/Web/StellaOps.Web/scripts/live-integrations-ui-bootstrap.mjs `STELLAOPS_BOOTSTRAP_KEY` that Authority exposes through `AUTHORITY_BOOTSTRAP_APIKEY`. -Scripted convergence path: +CLI convergence path: -```powershell -powershell -ExecutionPolicy Bypass -File scripts/register-local-integrations.ps1 ` - -Tenant demo-prod +```bash +# Authenticate into the target tenant first if needed +stella auth login + +# Bootstrap the default 13-entry local fixture catalog +stella config integrations bootstrap local ``` This converges the default local-ready lane to 13 healthy entries: @@ -132,33 +135,24 @@ This converges the default local-ready lane to 13 healthy entries: - NVD mirror - OSV mirror -Optional GitLab providers require Vault-backed credentials. The recommended -local flow is: +Optional GitLab providers are part of the same product-owned local bootstrap flow: -```powershell -# Reuse or rotate the local GitLab bootstrap PAT and write it to Vault. -powershell -ExecutionPolicy Bypass -File scripts/bootstrap-local-gitlab-secrets.ps1 ` - -VerifyRegistry +```bash +# Add the owned local GitLab SCM and CI fixtures +stella config integrations bootstrap local --include-gitlab -# Register SCM + CI using the bootstrapped authref://vault/gitlab#access-token -powershell -ExecutionPolicy Bypass -File scripts/register-local-integrations.ps1 ` - -Tenant demo-prod ` - -IncludeGitLab - -# Also requires GitLab registry enabled; uses authref://vault/gitlab#registry-basic -powershell -ExecutionPolicy Bypass -File scripts/register-local-integrations.ps1 ` - -Tenant demo-prod ` - -IncludeGitLab ` - -IncludeGitLabRegistry - -# Or do the GitLab-backed registration in one step -powershell -ExecutionPolicy Bypass -File scripts/register-local-integrations.ps1 ` - -Tenant demo-prod ` - -IncludeGitLab ` - -IncludeGitLabRegistry ` - -BootstrapGitLabSecrets +# Also add the optional local GitLab registry surface +stella config integrations bootstrap local --include-gitlab --include-gitlab-registry ``` +The CLI owns the local GitLab bootstrap contract: +- it mints the owned local `stella-local-integration` PAT against the compose fixture +- it stages the resulting `access-token` and `registry-basic` entries into the writable Vault secret-authority target +- it binds the returned `authref://vault/gitlab#...` values to the GitLab integrations and verifies health + +Legacy compatibility note: +- `scripts/register-local-integrations.ps1` and `scripts/bootstrap-local-gitlab-secrets.ps1` remain available for compatibility and debugging, but they are no longer the preferred product path for the local compose fixture lane + For a repeatable browser-driven proof of the same Integrations Hub path, run: ```powershell @@ -295,7 +289,15 @@ vault kv put secret/gitlab access-token="glpat-your-token" registry-basic="root: ``` Inline secret staging no longer requires a manual Vault write for GitLab-class -providers: +providers. For the Stella-owned local compose fixture lane, use: + +```bash +stella config integrations bootstrap local --include-gitlab +stella config integrations bootstrap local --include-gitlab --include-gitlab-registry +``` + +For production or customer-managed third-party systems, use the lower-level +Secret Authority commands directly: ```bash stella config integrations secrets targets @@ -418,14 +420,14 @@ GITLAB_ENABLE_REGISTRY=true GITLAB_ENABLE_PACKAGES=true \ **Stella Ops integration config (SCM / CI):** - Endpoint: `http://gitlab.stella-ops.local:8929` - AuthRef: `authref://vault/gitlab#access-token` -- Bootstrap helper: `powershell -ExecutionPolicy Bypass -File scripts/bootstrap-local-gitlab-secrets.ps1` +- Local fixture bootstrap: `stella config integrations bootstrap local --include-gitlab` **Stella Ops integration config (Registry):** - Endpoint: `http://gitlab.stella-ops.local:5050` - AuthRef: `authref://vault/gitlab#registry-basic` - Secret format: `username:personal-access-token` (local default: `root:`) - The Docker registry connector follows GitLab's `WWW-Authenticate: Bearer` challenge and exchanges this basic secret against `/jwt/auth` before retrying catalog and tag probes. -- `scripts/bootstrap-local-gitlab-secrets.ps1 -VerifyRegistry` reuses a valid local Vault secret when possible and otherwise rotates the local `stella-local-integration` PAT before writing both authrefs. +- Add registry coverage through `stella config integrations bootstrap local --include-gitlab --include-gitlab-registry` after enabling the heavy GitLab compose profile with registry support. --- diff --git a/docs/modules/authority/architecture.md b/docs/modules/authority/architecture.md index 33fec468b..519b29b69 100644 --- a/docs/modules/authority/architecture.md +++ b/docs/modules/authority/architecture.md @@ -30,8 +30,14 @@ * **OAuth2** grant types: * **Client Credentials** (service↔service, with mTLS or private_key_jwt) - * **Device Code** (CLI login on headless agents; optional) - * **Authorization Code + PKCE** (browser login for UI; optional) + * **Device Code** (CLI login on headless agents; optional when enabled by the deployment profile) + * **Authorization Code + PKCE** (browser login for UI and future human CLI flows; optional) + * **Password** (current local/dev bootstrap compatibility path for human CLI login; not the target long-term operator flow) +* **Current local/dev standard-plugin seed** (`etc/authority/plugins/standard.yaml`): + + * `stella-ops-ui`: `authorization_code refresh_token` + * `stellaops-cli`: public human client with `authorization_code password refresh_token`; localhost redirect URIs are PKCE-required, and the CLI currently uses this client for fresh-shell interactive username/password login + * `stellaops-cli-automation`: confidential automation client with `client_credentials` * **Sender constraint options** (choose per caller or per audience): * **DPoP** (Demonstration of Proof‑of‑Possession): proof JWT on each HTTP request, bound to the access token via `cnf.jkt`. @@ -117,8 +123,9 @@ plan? = // optional hint for UIs; not used for e * `security.senderConstraints.mtls.enforceForAudiences` forces the mTLS path when requested `aud`/`resource` values intersect high-value audiences (defaults include `signer`). Authority rejects clients attempting to use DPoP/basic secrets for these audiences. * Stored `certificateBindings` are authoritative: thumbprint, subject, issuer, serial number, and SAN values are matched against the presented certificate, with rotation grace applied to activation windows. Failures surface deterministic error codes (e.g. `certificate_binding_subject_mismatch`). * **private_key_jwt**: JWT‑based client auth + **DPoP** header (preferred for tools and CLI) - * **Device Code** (CLI): `POST /oauth/device/code` + `POST /oauth/token` poll - * **Authorization Code + PKCE** (UI): standard + * **Device Code** (CLI): `POST /oauth/device/code` + `POST /oauth/token` poll when enabled by the deployment profile + * **Authorization Code + PKCE** (UI/browser and future human CLI): standard + * **Password** (current local/dev human CLI bootstrap): `POST /token` using the seeded `stellaops-cli` public client **DPoP handshake (example)** diff --git a/docs/modules/cli/README.md b/docs/modules/cli/README.md index affdfe13d..a38738690 100644 --- a/docs/modules/cli/README.md +++ b/docs/modules/cli/README.md @@ -4,7 +4,7 @@ The `stella` CLI is the operator-facing Swiss army knife for scans, exports, pol ## Responsibilities - Deliver deterministic verbs for scan, diff, export, policy, and observability operations. -- Handle interactive and non-interactive authentication via Authority (device code, client credentials). +- Handle interactive and non-interactive authentication via Authority (seeded human username/password bootstrap, client credentials for automation). - Support offline kit workflows including bundle verification and seed installation. - Expose JSON outputs suitable for CI parity and golden tests. @@ -55,7 +55,7 @@ The `stella` CLI is the operator-facing Swiss army knife for scans, exports, pol **Key Responsibilities:** - Deterministic verbs for scan, diff, export, policy, and observability operations -- Interactive and non-interactive authentication via Authority (device code, client credentials) +- Interactive and non-interactive authentication via Authority (seeded human username/password bootstrap, client credentials for automation) - Offline kit workflows including bundle verification and seed installation - JSON outputs suitable for CI parity and golden tests diff --git a/docs/modules/cli/architecture.md b/docs/modules/cli/architecture.md index f9997ceca..c98e0b895 100644 --- a/docs/modules/cli/architecture.md +++ b/docs/modules/cli/architecture.md @@ -118,7 +118,8 @@ See [migration-v3.md](./guides/migration-v3.md) for user-facing migration instru * `auth login` - * Modes: **device‑code** (default), **client‑credentials** (service principal). + * Modes: **interactive password** (current default for seeded `stellaops-cli` in fresh local/dev shells), **client-credentials** (service principal), and future **device-code/PKCE** once enabled by the Authority profile. + * Startup Authority/crypto diagnostics are opt-in via `--verbose` and stay suppressed for structured output flags such as `--json`, `--raw`, and `--format json`. * Produces **Authority** access token (OpTok) + stores **DPoP** keypair in OS keychain. * `auth status` — show current issuer, subject, audiences, expiry. * `auth logout` — wipe cached tokens/keys. @@ -379,8 +380,17 @@ All verbs require scopes `policy.findings:read`, `signer.verify`, and (for Rekor ### 3.1 Token acquisition -* **Device‑code**: the CLI opens an OIDC device code flow against **Authority**; the browser login is optional for service principals. -* **Client‑credentials**: service principals use **private_key_jwt** or **mTLS** to get tokens. +* **Human interactive login (current local/dev behavior)**: when `STELLAOPS_AUTHORITY_CLIENT_ID` is unset, the CLI defaults to the seeded `stellaops-cli` public client and prompts for username/password if they are not preconfigured. That login uses the current password-grant bootstrap path. +* **Client-credentials**: service principals use a confidential client such as the seeded local/dev `stellaops-cli-automation`, or a deployment-specific confidential client with **private_key_jwt**, `client_secret`, or **mTLS**. +* **Device-code/PKCE**: still the intended long-term human posture, but not yet the default fresh-shell path in the current local/dev Authority profile. + +### 3.1.1 Bootstrap client inventory (current local/dev standard profile) + +* Authority seeds first-party CLI clients from `etc/authority/plugins/standard.yaml`. +* `stellaops-cli` is the default human client ID when no explicit Authority client ID is configured. +* `stellaops-cli-automation` is the seeded confidential automation client for non-interactive local/dev flows. +* Production deployments should override local secrets and may disable the password grant entirely once device-code or browser-mediated PKCE is enabled. +* Startup Authority and crypto diagnostics are emitted only for verbose human-readable invocations; structured output commands stay clean even when optional crypto providers are unavailable. ### 3.2 DPoP key management @@ -585,7 +595,7 @@ sequenceDiagram participant SG as Signer participant AT as Attestor - CLI->>Auth: device code flow (DPoP) + CLI->>Auth: password grant bootstrap on seeded stellaops-cli client Auth-->>CLI: OpTok (aud=scanner) CLI->>SW: POST /scans { imageRef, attest:true } @@ -629,10 +639,13 @@ sequenceDiagram ## 19) Example CI snippets -**GitHub Actions (post‑build)** +**GitHub Actions (post-build)** ```yaml -- name: Login (device code w/ OIDC broker) +- name: Login (service principal) + env: + STELLAOPS_AUTHORITY_CLIENT_ID: stellaops-cli-automation + STELLAOPS_AUTHORITY_CLIENT_SECRET: ${{ secrets.STELLAOPS_AUTHORITY_CLIENT_SECRET }} run: stellaops auth login --json --authority ${{ secrets.AUTHORITY_URL }} - name: Scan diff --git a/docs/modules/cli/guides/admin/admin-reference.md b/docs/modules/cli/guides/admin/admin-reference.md index be2e88a79..25fea3c5f 100644 --- a/docs/modules/cli/guides/admin/admin-reference.md +++ b/docs/modules/cli/guides/admin/admin-reference.md @@ -4,7 +4,7 @@ ## Overview -The `stella admin` command group provides administrative operations for platform management. These commands require elevated authentication and are used for policy management, user administration, feed configuration, and system maintenance. +The `stella admin` command group provides administrative operations for platform management. These commands require elevated authentication and are used for policy management, user administration, feed configuration, diagnostics, and system maintenance. ## Authentication @@ -30,6 +30,7 @@ Admin commands require one of the following authentication methods: | `stella admin policy` | `admin.policy` | Policy management operations | | `stella admin users` | `admin.users` | User administration | | `stella admin feeds` | `admin.feeds` | Feed management | +| `stella admin diagnostics` | `ops.health` / `ops.admin` | Platform readiness and operator diagnostics | | `stella admin system` | `admin.platform` | System operations | ## Command Reference @@ -327,6 +328,42 @@ stella admin feeds history --source osv --limit 50 --- +### stella admin diagnostics + +Readiness and operator diagnostics backed by the Platform service. + +#### stella admin diagnostics health + +Show the canonical platform readiness summary, including setup-blocking required dependencies and configured optional post-boot services. + +**Usage:** +```bash +stella admin diagnostics health [--detail] [--format ] +``` + +**Options:** +- `--detail` - Include endpoint information in the table output +- `--format ` - Output format: `table` (default), `json` + +**Notes:** +- Required dependencies are the same blockers used by `stella setup status` and setup finalization. +- Optional dependencies are configured post-boot services discovered by the Platform readiness endpoint. +- JSON output is camelCase and intended to stay machine-readable. + +**Examples:** +```bash +# Show readiness in the default table view +stella admin diagnostics health + +# Include endpoint details for each dependency +stella admin diagnostics health --detail + +# Emit the readiness payload for automation +stella admin diagnostics health --format json +``` + +--- + ### stella admin system System management and health commands. @@ -408,6 +445,7 @@ Admin commands call the following backend APIs: | `/api/v1/admin/feeds/status` | GET | `stella admin feeds status` | | `/api/v1/admin/feeds/{id}/refresh` | POST | `stella admin feeds refresh` | | `/api/v1/admin/feeds/{id}/history` | GET | `stella admin feeds history` | +| `/api/v1/platform/health/readiness` | GET | `stella admin diagnostics health` | | `/api/v1/admin/system/status` | GET | `stella admin system status` | | `/api/v1/admin/system/info` | GET | `stella admin system info` | diff --git a/docs/modules/cli/guides/commands/auth.md b/docs/modules/cli/guides/commands/auth.md index a2430f918..d5356eae6 100644 --- a/docs/modules/cli/guides/commands/auth.md +++ b/docs/modules/cli/guides/commands/auth.md @@ -11,11 +11,17 @@ Acquire and cache an access token using the configured Authority credentials. ```bash stella auth login stella auth login --force +STELLAOPS_AUTHORITY_CLIENT_ID=stellaops-cli-automation \ +STELLAOPS_AUTHORITY_CLIENT_SECRET=stellaops-local-cli-automation-secret \ +stella auth login --json ``` Notes: - `--force` ignores cached tokens and forces re-authentication. - Credential sources are configuration-driven (profile/env). This command does not accept raw tokens on the command line. +- When no Authority client ID is configured, the CLI defaults to the seeded human client `stellaops-cli`. +- In a fresh interactive local/dev shell, `auth login` prompts for username/password and uses the current password-grant bootstrap path on that human client. +- For non-interactive automation, configure a confidential client such as the seeded local/dev `stellaops-cli-automation`. ### auth status / whoami / logout @@ -82,4 +88,3 @@ Flags: ## Offline notes - `auth login` and token mint/delegate require connectivity to Authority. - `auth revoke verify`, `status`, `whoami`, and `logout` can operate using local cached state. - diff --git a/docs/modules/cli/guides/commands/reference.md b/docs/modules/cli/guides/commands/reference.md index c5267a8c1..66e21ceb9 100644 --- a/docs/modules/cli/guides/commands/reference.md +++ b/docs/modules/cli/guides/commands/reference.md @@ -59,6 +59,42 @@ stella config integrations [options] - `health` - Query connector health - `impact` - Show workflow impact summary - `discover` - Discover provider resources such as repositories, projects, jobs, pipelines, or tags +- `bootstrap local` - Bootstrap the Stella-owned local compose fixture catalog end to end +- `secrets targets` / `secrets upsert-bundle` - Stage authref-backed secret bundles against writable secret-authority targets + +### stella config integrations bootstrap local + +Bootstrap the owned local compose fixture lane without dropping into fixture-native APIs. + +**Usage:** +```bash +stella config integrations bootstrap local [--include-gitlab] [--include-gitlab-registry] [--format table|json] +``` + +**Options:** +| Option | Description | +|--------|-------------| +| `--include-gitlab` | Add the local GitLab Server and GitLab CI fixtures. The CLI mints the owned local PAT and stages it into Vault automatically. | +| `--include-gitlab-registry` | Add the optional local GitLab container registry fixture. Requires the heavy GitLab compose profile with `GITLAB_ENABLE_REGISTRY=true`. | +| `--format ` | Output format. `json` is machine-readable and exits non-zero when any selected integration is unhealthy. | + +**Examples:** +```bash +# Bootstrap the default 13-entry local compose fixture catalog +stella config integrations bootstrap local + +# Add the owned local GitLab SCM and CI fixtures +stella config integrations bootstrap local --include-gitlab + +# Add the full 16-entry local fixture catalog including GitLab registry +stella config integrations bootstrap local --include-gitlab --include-gitlab-registry --format json +``` + +**Contract:** +- This command is only for Stella-owned local compose fixtures. +- Default mode creates or updates 13 deterministic local integrations and runs test plus health verification for each. +- `--include-gitlab` stages the managed local GitLab PAT through Secret Authority and binds the resulting `authref://...` values to GitLab Server and GitLab CI. +- Production and customer-managed third-party systems still use `secrets targets`, `secrets upsert-bundle`, and explicit `create` or `update` operations with operator-provided credentials. **Examples:** ```bash @@ -95,6 +131,7 @@ stella config integrations create \ **Notes:** - `providers` returns `isTestOnly`, `supportsDiscovery`, and `supportedResourceTypes`. +- `bootstrap local` returns exit code `0` only when every selected integration tests healthy and reports healthy runtime status. - Deprecated `stella integrations *` routes are preserved as aliases and forward to `stella config integrations *`. - Unsupported discovery requests return a client error instead of silently falling back to sample data. @@ -605,25 +642,27 @@ Authenticate with platform (interactive). **Usage:** ```bash -stella auth login [--authority ] [--verbose] +stella auth login [--force] [--verbose] ``` **Example:** ```bash -# Interactive login (opens browser) +# Interactive login on the seeded human client stella auth login -# Specify Authority URL -stella auth login --authority https://auth.stellaops.example.com +# Re-authenticate even when a cached token exists +stella auth login --force ``` **Output:** ``` -Opening browser for authentication... -✅ Logged in as alice@example.com -Token saved to ~/.stellaops/tokens.json +Authority username: admin +Authority password for admin: +Login successful. Access token expires at 2026-04-16 12:00:00Z. ``` +When no Authority client ID is configured, the CLI defaults to the seeded human client `stellaops-cli` and prompts for username/password in an interactive shell. For automation, configure a confidential client such as `stellaops-cli-automation`. + --- ### stella auth logout diff --git a/docs/modules/cli/guides/setup-guide.md b/docs/modules/cli/guides/setup-guide.md index 0b1a0a980..c5b8e2b5e 100644 --- a/docs/modules/cli/guides/setup-guide.md +++ b/docs/modules/cli/guides/setup-guide.md @@ -10,6 +10,22 @@ This guide covers the current `stella setup` command set for installation-scoped - It uses the Platform setup session APIs as the source of truth. Local files are not the authoritative setup state. - Tenant onboarding such as integrations, secrets providers, advisory sources, environments, agents, and notifications happens on the authenticated `/setup/*` surfaces and the corresponding CLI command groups. +## Retained Secrets + +- Setup session `draftValues` remain sanitized. Passwords and similar secret-bearing fields are not echoed back through normal session reads. +- When the backend needs a secret to survive resume/apply, it retains that value in protected companion storage and exposes only `secretDrafts` metadata in `stella setup status --json`. +- Re-running or resuming setup without re-entering a retained secret keeps the server-retained value for probe/apply. +- Supplying a new secret value replaces the retained one for that setup session. +- Backend protection key precedence is `Platform:Setup:SecretProtectionKey`, `STELLAOPS_SECRETS_ENCRYPTION_KEY`, then `STELLAOPS_BOOTSTRAP_KEY`. + +## Operational Readiness + +- `stella setup status` reports required control-plane readiness only. +- Text output prints an `Operational readiness` section with the current required counts, blocker summary, and any blocking dependencies. +- `stella setup status --json` includes a `readiness` object for automation. This payload is intentionally required-only; optional post-boot services are not mixed into setup gating. +- `stella setup run` finalization stops with an explicit error if any required readiness dependency is still blocked. +- Optional post-boot services belong to `stella admin diagnostics health`, not to setup completion. + ## Quick Start ```bash @@ -35,6 +51,29 @@ stella setup reset --step cache stella setup reset --all --force ``` +## First Auth After Setup + +Fresh local/dev installs seed first-party CLI auth clients through `etc/authority/plugins/standard.yaml`. + +Human operators: + +```bash +stella auth login +``` + +- The CLI defaults to the seeded human client `stellaops-cli`. +- In a fresh interactive shell, if no Authority username/password is preconfigured, the CLI prompts and uses the current password-grant bootstrap path. + +Automation: + +```bash +export STELLAOPS_AUTHORITY_CLIENT_ID=stellaops-cli-automation +export STELLAOPS_AUTHORITY_CLIENT_SECRET=stellaops-local-cli-automation-secret +stella auth login --json +``` + +Production deployments should replace local/dev secrets and can tighten the allowed grants for these first-party clients. + ## Supported Steps | Step | Purpose | Notes | @@ -91,7 +130,11 @@ Show the current installation-scoped session. Options: - `--session` - explicit setup session ID -- `--json` - machine-readable output +- `--json` - machine-readable output including sanitized `draftValues` and retained-secret `secretDrafts` metadata + +Notes: +- The status surface carries required-only readiness because setup is allowed to finish before optional post-boot services come online. +- Use `stella admin diagnostics health` when you need the full required-plus-optional platform readiness view. ### `stella setup reset` @@ -146,13 +189,34 @@ crypto: When `stella setup` finishes, the next work is tenant onboarding, not more bootstrap steps. -Typical next commands: +If you are bringing up the Stella-owned local compose fixture lane, authenticate first and use the owned fixture bootstrap command: + +```bash +# Authenticate into the target tenant if needed +stella auth login + +# Bootstrap the default 13-entry local fixture catalog +stella config integrations bootstrap local + +# Add the owned local GitLab SCM and CI fixtures +stella config integrations bootstrap local --include-gitlab + +# Also add the optional local GitLab registry fixture +stella config integrations bootstrap local --include-gitlab --include-gitlab-registry +``` + +The local bootstrap contract is explicit: +- default mode creates or updates the 13 deterministic local compose entries and verifies their health +- `--include-gitlab` mints the owned local GitLab PAT, stages it through Secret Authority into Vault, then binds GitLab Server and GitLab CI with the returned `authref://...` values +- `--include-gitlab-registry` adds the optional GitLab container registry entry for the heavy GitLab compose profile with registry enabled via `GITLAB_ENABLE_REGISTRY=true` + +For production or customer-managed third-party systems, use bring-your-own-secret onboarding instead of the local fixture bootstrap: ```bash # Discover writable secret-authority targets stella config integrations secrets targets -# Stage a GitLab access token into a secret-authority target +# Stage an operator-provided GitLab access token into a secret-authority target stella config integrations secrets upsert-bundle \ --bundle gitlab-server \ --target \ @@ -161,10 +225,10 @@ stella config integrations secrets upsert-bundle \ # Create the GitLab server integration with the returned authref stella config integrations create \ - --name local-gitlab \ + --name customer-gitlab \ --type scm \ --provider gitlabserver \ - --endpoint http://gitlab.stella-ops.local:8929 \ + --endpoint https://gitlab.example.com \ --authref authref://vault/gitlab/server#access-token ``` @@ -197,11 +261,24 @@ Check: - the runtime cache service is reachable - the host/port/password values are correct +### Setup finalization is blocked by operational readiness + +```bash +stella setup status --json +stella admin diagnostics health --detail +``` + +Check: +- which required dependency is still reported as `blocked` in the setup status `readiness` payload +- whether `frontdoor` or `authority` is failing its live probe +- whether optional post-boot service degradation is being mistaken for a setup blocker + ### Setup reports that `vault` or `scm` is no longer a supported step That is expected. Those flows moved out of installation bootstrap. Use: +- `stella config integrations bootstrap local` for the Stella-owned local compose fixture lane - `stella config integrations secrets *` - `stella config integrations create` - the authenticated `/setup/integrations/*` UI @@ -209,6 +286,7 @@ Use: ## Related Commands - `stella setup status --json` +- `stella config integrations bootstrap local` - `stella config integrations providers` - `stella config integrations secrets targets` - `stella config integrations secrets upsert-bundle` diff --git a/docs/modules/cli/guides/troubleshooting.md b/docs/modules/cli/guides/troubleshooting.md index 57567a32c..ddc9f32c6 100644 --- a/docs/modules/cli/guides/troubleshooting.md +++ b/docs/modules/cli/guides/troubleshooting.md @@ -719,6 +719,8 @@ stella --verbose scan docker://nginx:latest stella --verbose crypto sign --provider gost --file doc.pdf ``` +Startup Authority and crypto diagnostics are emitted only in verbose human-readable flows. Structured output commands such as `--json`, `--raw`, and `--format json` suppress that startup noise so stdout remains parseable. + --- ## Getting Help diff --git a/docs/modules/platform/architecture.md b/docs/modules/platform/architecture.md index 6343ad82f..b00434d94 100644 --- a/docs/modules/platform/architecture.md +++ b/docs/modules/platform/architecture.md @@ -44,8 +44,9 @@ Current implementation status (2026-03-05): - `PacksRegistry`: Postgres metadata/state + seed-fs payload channel for pack/provenance/attestation blobs; startup rejects `rustfs` and unknown object-store drivers. - `TaskRunner`: Postgres run state/log/approval + seed-fs artifact payload channel; startup rejects `rustfs` and unknown object-store drivers in both WebService and Worker. - `RiskEngine`: Postgres-backed result store (`riskengine.risk_score_results`) with explicit in-memory test fallback. -- `Replay`: Postgres snapshot index + seed-fs snapshot blob store; startup rejects `rustfs` and unknown object-store drivers. +- `Replay`: Postgres snapshot index + seed-fs snapshot blob store; startup rejects `inmemory` outside `Testing`, rejects `rustfs`, and rejects unknown object-store drivers. - `OpsMemory`: connection precedence aligned to `ConnectionStrings:OpsMemory -> ConnectionStrings:Default`, with non-development fail-fast. +- `Platform`: Postgres-backed platform-owned state (`platform.*`, `release.*`) with explicit `Testing`-only in-memory fallback; startup rejects missing `Platform:Storage:PostgresConnectionString` outside `Testing`. ## Platform Runtime Read-Model Boundary Policy (Point 4 / Sprint 20260305-005) diff --git a/docs/modules/platform/platform-service.md b/docs/modules/platform/platform-service.md index b2fa20d03..3ab0f306d 100644 --- a/docs/modules/platform/platform-service.md +++ b/docs/modules/platform/platform-service.md @@ -25,9 +25,13 @@ Provide a single, deterministic aggregation layer for cross-service UX workflows ### Health aggregation - GET `/api/v1/platform/health/summary` +- GET `/api/v1/platform/health/readiness` - GET `/api/v1/platform/health/dependencies` - GET `/api/v1/platform/health/incidents` - GET `/api/v1/platform/health/metrics` +- `GET /api/v1/platform/health/readiness` is the canonical readiness contract for both setup gating and post-boot diagnostics. +- Required setup-blocking dependencies currently include the five converged setup steps (`database`, `cache`, `migrations`, `admin-bootstrap`, `crypto-profile`) plus live `frontdoor` and `authority` probes. +- Optional post-boot dependencies are discovered from configured `STELLAOPS_*_URL` endpoints and currently include `release-orchestrator`, `policy-engine`, `scanner`, `signals`, `notify`, `scheduler`, `registry-token`, `sbomservice`, `packsregistry`, and `advisoryai`. ### Quota aggregation - GET `/api/v1/platform/quotas/summary` @@ -162,7 +166,7 @@ Provide a single, deterministic aggregation layer for cross-service UX workflows - `release.topology_workflow_inventory` (workflow template projection for topology routes) - `release.topology_gate_profile_inventory` (gate profile projection bound to region/environment inventory) - `release.topology_sync_watermarks` (projection synchronization watermark state for deterministic replay/cutover checks) -- Schema reference: `docs/db/schemas/platform.sql` (PostgreSQL; in-memory stores used until storage driver switches). +- Schema reference: `docs/db/schemas/platform.sql` (PostgreSQL; in-memory stores are `Testing`-only harnesses). ## Dependencies - Authority (tenant/user identity, quotas, RBAC) @@ -268,9 +272,20 @@ Current runtime behavior: Migration `064_EnvironmentSettingsInstallationScopeConvergence.sql` upgrades older compose-created tables that still used the legacy `(tenant_id, key)` primary key. -- The persisted store keeps only non-sensitive draft configuration plus step - state, timestamps, and check results. Secret material is still expected to be - staged through a secret authority rather than stored in wizard session state. +- The persisted session document keeps only non-sensitive `draftValues` plus + step state, timestamps, and check results. +- Sensitive step inputs retained for resume/apply are stored separately in + `platform.setup_session_secrets` via migration + `066_PlatformSetupSessionSecrets.sql`. Session reads expose only + `secretDrafts` metadata (`key`, `stepId`, `updatedAtUtc`), never plaintext. +- Probe/apply hydrate retained setup secrets server-side, and finalize deletes + the retained secret records for the completed session. +- Setup session reads now also include a required-only readiness snapshot so + CLI and UI status flows can show operational blockers without treating + optional post-boot services as setup failures. +- Setup secret protection key precedence is + `Platform:Setup:SecretProtectionKey`, + `STELLAOPS_SECRETS_ENCRYPTION_KEY`, then `STELLAOPS_BOOTSTRAP_KEY`. - The live wizard now owns only the five control-plane steps the running control plane can truthfully validate and converge: `database`, `cache`, `migrations`, `admin`, and `crypto`. @@ -292,6 +307,11 @@ Current runtime behavior: - `POST /api/v1/setup/sessions/{sessionId}/finalize` - Finalize the current session with convergence checks - `POST /api/v1/setup/sessions/finalize` - Compatibility finalize path +Session payloads distinguish: +- `draftValues` - non-sensitive persisted config only +- `secretDrafts` - retained-secret metadata only; no plaintext secret values +- `readiness` - required-only operational readiness summary for installation bootstrap + #### Steps - `POST /api/v1/setup/sessions/{sessionId}/steps/{stepId}/probe` - Run a diagnostic probe without completing the step - `POST /api/v1/setup/sessions/{sessionId}/steps/{stepId}/apply` - Apply the current step and persist the new state @@ -354,9 +374,10 @@ to the authenticated onboarding surfaces instead. - After setup is marked complete, anonymous setup session reads and mutations return `401` and the normal authenticated `platform.setup.*` policies apply. - `Finalize` succeeds only after every required control-plane step has - converged. + converged and the required readiness dependencies are not blocked. ### Security and scopes +- Health: `ops.health` (summary, readiness, dependencies, incidents), `ops.admin` (metrics) - Read: `platform.setup.read` - Write: `platform.setup.write` - Admin: `platform.setup.admin` diff --git a/docs/modules/ui/architecture.md b/docs/modules/ui/architecture.md index 47ea22d9f..40c1c809c 100644 --- a/docs/modules/ui/architecture.md +++ b/docs/modules/ui/architecture.md @@ -199,7 +199,7 @@ Each feature folder builds as a **standalone route** (lazy loaded). All HTTP sha * **Implicit route weighting**: global search always prefers current-page evidence first and renders cross-scope overflow as a quiet secondary section only when it materially improves the answer. * **Answer-first search**: every non-empty search renders a visible answer panel before raw cards; the panel resolves to `grounded`, `clarify`, or `insufficient` and never leaves the operator with a blank result area. * **Page-owned self-serve questions**: priority pages define common questions and clarifying prompts in the shared search context registry; empty-state search uses those as starter questions and answer states reuse them as follow-up or clarification buttons. -* **Priority route rollout**: mocked end-to-end journeys explicitly cover findings, policy, doctor, timeline, and release-control routes; live ingestion-backed route verification remains required where corpus parity already exists. +* **Priority route rollout**: integration activity, export center, offline-kit bundle management, setup topology, configuration pane, policy governance conflicts, and image security now require live route rechecks against mounted flows. Mock-only journey coverage is no longer sufficient for those routes. * **Suggestion executability gate**: contextual/page starters preflight through backend viability signals before render so dead suggestions are suppressed instead of being taught to the user. * **Ambient payload activation**: each global search request sends ambient context (`currentRoute`, `visibleEntityKeys`, `recentSearches`, `sessionId`, optional `lastAction`) so AdvisoryAI can apply contextual ranking and answer shaping. * **Contract governance**: contextual chips follow `docs/modules/ui/search-chip-context-contract.md`, while self-serve questions, rollout ownership, and fallback states follow `docs/modules/ui/search-self-serve-contract.md`; both are implemented in `search-context.registry.ts`. diff --git a/docs/modules/ui/component-preservation-map/components/dead/features/lineage/components/node-diff-table/NodeDiffTableComponent.md b/docs/modules/ui/component-preservation-map/components/dead/features/lineage/components/node-diff-table/NodeDiffTableComponent.md deleted file mode 100644 index c11dad9ec..000000000 --- a/docs/modules/ui/component-preservation-map/components/dead/features/lineage/components/node-diff-table/NodeDiffTableComponent.md +++ /dev/null @@ -1,47 +0,0 @@ -# NodeDiffTableComponent - -## Status Snapshot -- Classification: `dead` -- Confidence: `high` -- Recommendation: `investigate` -- Preservation value: `medium` -- Feature branch: `Lineage` -- Source: `features/lineage/components/node-diff-table/diff-table.component.ts` -- Selector: `app-node-diff-table` - -## What Is It? -Node Diff Table appears to be a dedicated feature surface in the Lineage / Components / Node Diff Table area. - -## Why It Likely Fell Out Of The Product -No route or runtime references remain in the current Angular shell, which suggests the surface was dropped or replaced. - -## What Is Worth Preserving -Preserve the underlying workflow only if current product docs still claim the capability or if the component contains unique UI concepts. - -## Likely Successor Or Merge Target -Needs branch-level review against current IA - -## Static Evidence -### Routed paths -- none - -### Route files -- none - -### Menu surfaces -- none - -### Absolute page-action surfaces -- none - -### Runtime references outside routes/tests -- none - -## Related Docs -- [docs/modules/ui/README.md](../../../../../../../README.md) -- [docs/modules/ui/architecture.md](../../../../../../../architecture.md) - -## Next-Pass Questions -- Check whether newer routed pages already absorbed this workflow under a different name. -- Review component templates and services for reusable UX or domain language worth salvaging. -- Validate the preservation call against current Stella Ops product docs before archival. diff --git a/docs/modules/ui/component-preservation-map/components/dead/lineage/README.md b/docs/modules/ui/component-preservation-map/components/dead/lineage/README.md index 00411fff8..11dc0b763 100644 --- a/docs/modules/ui/component-preservation-map/components/dead/lineage/README.md +++ b/docs/modules/ui/component-preservation-map/components/dead/lineage/README.md @@ -2,7 +2,7 @@ ## Branch Summary - Classification bucket: `dead` -- Components in branch: 14 +- Components in branch: 13 - Default recommendation: `investigate` - Preservation value: `medium` @@ -27,5 +27,4 @@ Needs branch-level review against current IA - [LineageProvenanceCompareComponent](../features/lineage/components/lineage-provenance-compare/LineageProvenanceCompareComponent.md) - `investigate`, features/lineage/components/lineage-provenance-compare/lineage-provenance-compare.component.ts - [LineageSbomDiffComponent](../features/lineage/components/lineage-sbom-diff/LineageSbomDiffComponent.md) - `investigate`, features/lineage/components/lineage-sbom-diff/lineage-sbom-diff.component.ts - [LineageVexDiffComponent](../features/lineage/components/lineage-vex-diff/LineageVexDiffComponent.md) - `investigate`, features/lineage/components/lineage-vex-diff/lineage-vex-diff.component.ts -- [NodeDiffTableComponent](../features/lineage/components/node-diff-table/NodeDiffTableComponent.md) - `investigate`, features/lineage/components/node-diff-table/diff-table.component.ts - [PinnedPanelComponent](../features/lineage/components/pinned-explanation/pinned-panel/PinnedPanelComponent.md) - `investigate`, features/lineage/components/pinned-explanation/pinned-panel/pinned-panel.component.ts diff --git a/docs/modules/ui/component-preservation-map/inventory.json b/docs/modules/ui/component-preservation-map/inventory.json index 97ec433ec..c57a8dbe1 100644 --- a/docs/modules/ui/component-preservation-map/inventory.json +++ b/docs/modules/ui/component-preservation-map/inventory.json @@ -1150,28 +1150,6 @@ ], "likelyDestination": "Needs branch-level review against current IA" }, - { - "className": "NodeDiffTableComponent", - "selector": "app-node-diff-table", - "classification": "dead", - "confidence": "high", - "recommendation": "investigate", - "preservationValue": "medium", - "family": "lineage", - "branchKey": "lineage", - "branchTitle": "Lineage", - "source": "features/lineage/components/node-diff-table/diff-table.component.ts", - "routePaths": [], - "routeFiles": [], - "menuSurfaceFiles": [], - "actionSurfaceFiles": [], - "runtimeRefs": [], - "relatedDocs": [ - "docs/modules/ui/README.md", - "docs/modules/ui/architecture.md" - ], - "likelyDestination": "Needs branch-level review against current IA" - }, { "className": "PinnedPanelComponent", "selector": "app-pinned-panel", diff --git a/docs/qa/feature-checks/runs/web/configuration-pane/run-008/evidence/playwright-ui-evidence.txt b/docs/qa/feature-checks/runs/web/configuration-pane/run-008/evidence/playwright-ui-evidence.txt new file mode 100644 index 000000000..35f742b17 --- /dev/null +++ b/docs/qa/feature-checks/runs/web/configuration-pane/run-008/evidence/playwright-ui-evidence.txt @@ -0,0 +1,15 @@ +Tier2 strict UI evidence for configuration-pane +CapturedAtUtc: 2026-04-15T17:03:18Z +Playwright command: PLAYWRIGHT_BASE_URL=https://stella-ops.local npx playwright test --config playwright.config.ts tests/e2e/ui-truthful-state-cutover.recheck.spec.ts --workers=1 --reporter=list +Playwright test mapping: rechecks mounted truthful-state routes after the mock-data cutover +Route: /setup/configuration-pane +Assertions: +- heading "Configuration" is visible +- "Manage platform integrations and settings" is visible +- removed seeded "Primary Database" row is absent +Command output snippet: +- Running 2 tests using 1 worker +- ✓ rechecks mounted truthful-state routes after the mock-data cutover (22.4s) +- ✓ rechecks policy governance conflicts without fabricated preview content (11.2s) +- 2 passed (34.9s) +Result: pass diff --git a/docs/qa/feature-checks/runs/web/configuration-pane/run-008/tier2-ui-check.json b/docs/qa/feature-checks/runs/web/configuration-pane/run-008/tier2-ui-check.json new file mode 100644 index 000000000..b9bf8bb9e --- /dev/null +++ b/docs/qa/feature-checks/runs/web/configuration-pane/run-008/tier2-ui-check.json @@ -0,0 +1,32 @@ +{ + "type": "ui", + "module": "web", + "feature": "configuration-pane", + "runId": "run-008", + "baseUrl": "https://stella-ops.local", + "capturedAtUtc": "2026-04-15T17:03:18Z", + "playwrightSpec": "src/Web/StellaOps.Web/tests/e2e/ui-truthful-state-cutover.recheck.spec.ts", + "playwrightTests": [ + "rechecks mounted truthful-state routes after the mock-data cutover" + ], + "steps": [ + { + "description": "Open the mounted configuration pane route.", + "action": "navigate", + "target": "/setup/configuration-pane", + "expected": "The route renders Configuration with the live integrations-backed summary.", + "result": "pass", + "stepCapturedAtUtc": "2026-04-15T17:03:18Z" + }, + { + "description": "Verify removed seeded connector rows are absent.", + "action": "assert", + "target": "Configuration page body", + "expected": "\"Primary Database\" does not appear on the mounted route after the truthful-state cutover.", + "result": "pass", + "stepCapturedAtUtc": "2026-04-15T17:03:18Z" + } + ], + "evidence": "docs/qa/feature-checks/runs/web/configuration-pane/run-008/evidence/playwright-ui-evidence.txt", + "verdict": "pass" +} diff --git a/docs/qa/feature-checks/runs/web/image-security-release-backed-ui/run-001/evidence/playwright-ui-evidence.txt b/docs/qa/feature-checks/runs/web/image-security-release-backed-ui/run-001/evidence/playwright-ui-evidence.txt new file mode 100644 index 000000000..3683f3e57 --- /dev/null +++ b/docs/qa/feature-checks/runs/web/image-security-release-backed-ui/run-001/evidence/playwright-ui-evidence.txt @@ -0,0 +1,17 @@ +Tier2 strict UI evidence for image-security-release-backed-ui +CapturedAtUtc: 2026-04-15T17:03:18Z +Playwright command: PLAYWRIGHT_BASE_URL=https://stella-ops.local npx playwright test --config playwright.config.ts tests/e2e/ui-truthful-state-cutover.recheck.spec.ts --workers=1 --reporter=list +Playwright test mapping: rechecks mounted truthful-state routes after the mock-data cutover +Route: /security/images +Assertions: +- heading "Image Security" is visible +- "No image security scope selected" empty state is visible before selecting a release +- selecting a live release renders "Release images" +- VEX tab shows "The current findings contract exposes VEX status only." +- Evidence tab shows "The mounted image-security route has access to release-level evidence posture only." +Command output snippet: +- Running 2 tests using 1 worker +- ✓ rechecks mounted truthful-state routes after the mock-data cutover (22.4s) +- ✓ rechecks policy governance conflicts without fabricated preview content (11.2s) +- 2 passed (34.9s) +Result: pass diff --git a/docs/qa/feature-checks/runs/web/image-security-release-backed-ui/run-001/tier2-ui-check.json b/docs/qa/feature-checks/runs/web/image-security-release-backed-ui/run-001/tier2-ui-check.json new file mode 100644 index 000000000..2af5d43c8 --- /dev/null +++ b/docs/qa/feature-checks/runs/web/image-security-release-backed-ui/run-001/tier2-ui-check.json @@ -0,0 +1,48 @@ +{ + "type": "ui", + "module": "web", + "feature": "image-security-release-backed-ui", + "runId": "run-001", + "baseUrl": "https://stella-ops.local", + "capturedAtUtc": "2026-04-15T17:03:18Z", + "playwrightSpec": "src/Web/StellaOps.Web/tests/e2e/ui-truthful-state-cutover.recheck.spec.ts", + "playwrightTests": [ + "rechecks mounted truthful-state routes after the mock-data cutover" + ], + "steps": [ + { + "description": "Open the mounted image security route.", + "action": "navigate", + "target": "/security/images", + "expected": "The route renders Image Security with release and environment selectors plus a truthful empty state.", + "result": "pass", + "stepCapturedAtUtc": "2026-04-15T17:03:18Z" + }, + { + "description": "Select a live release from the scope bar.", + "action": "select", + "target": "Release selector", + "expected": "The summary tab renders Release images from live release-backed data.", + "result": "pass", + "stepCapturedAtUtc": "2026-04-15T17:03:18Z" + }, + { + "description": "Open the VEX tab and verify truthful contract messaging.", + "action": "click+assert", + "target": "VEX tab", + "expected": "The mounted route reports that the current findings contract exposes VEX status only.", + "result": "pass", + "stepCapturedAtUtc": "2026-04-15T17:03:18Z" + }, + { + "description": "Open the Evidence tab and verify release-level limitation copy.", + "action": "click+assert", + "target": "Evidence tab", + "expected": "The mounted route reports release-level evidence posture only instead of fake image-level evidence rows.", + "result": "pass", + "stepCapturedAtUtc": "2026-04-15T17:03:18Z" + } + ], + "evidence": "docs/qa/feature-checks/runs/web/image-security-release-backed-ui/run-001/evidence/playwright-ui-evidence.txt", + "verdict": "pass" +} diff --git a/docs/qa/feature-checks/runs/web/integration-hub-ui/run-002/evidence/playwright-ui-evidence.txt b/docs/qa/feature-checks/runs/web/integration-hub-ui/run-002/evidence/playwright-ui-evidence.txt new file mode 100644 index 000000000..a64638f43 --- /dev/null +++ b/docs/qa/feature-checks/runs/web/integration-hub-ui/run-002/evidence/playwright-ui-evidence.txt @@ -0,0 +1,15 @@ +Tier2 strict UI evidence for integration-hub-ui +CapturedAtUtc: 2026-04-15T17:03:18Z +Playwright command: PLAYWRIGHT_BASE_URL=https://stella-ops.local npx playwright test --config playwright.config.ts tests/e2e/ui-truthful-state-cutover.recheck.spec.ts --workers=1 --reporter=list +Playwright test mapping: rechecks mounted truthful-state routes after the mock-data cutover +Route: /setup/integrations/activity +Assertions: +- heading "Integration Activity" is visible +- "Audit trail for all integration lifecycle events" is visible +- removed "Mock data for development" text is absent +Command output snippet: +- Running 2 tests using 1 worker +- ✓ rechecks mounted truthful-state routes after the mock-data cutover (22.4s) +- ✓ rechecks policy governance conflicts without fabricated preview content (11.2s) +- 2 passed (34.9s) +Result: pass diff --git a/docs/qa/feature-checks/runs/web/integration-hub-ui/run-002/tier2-ui-check.json b/docs/qa/feature-checks/runs/web/integration-hub-ui/run-002/tier2-ui-check.json new file mode 100644 index 000000000..181d8afb7 --- /dev/null +++ b/docs/qa/feature-checks/runs/web/integration-hub-ui/run-002/tier2-ui-check.json @@ -0,0 +1,32 @@ +{ + "type": "ui", + "module": "web", + "feature": "integration-hub-ui", + "runId": "run-002", + "baseUrl": "https://stella-ops.local", + "capturedAtUtc": "2026-04-15T17:03:18Z", + "playwrightSpec": "src/Web/StellaOps.Web/tests/e2e/ui-truthful-state-cutover.recheck.spec.ts", + "playwrightTests": [ + "rechecks mounted truthful-state routes after the mock-data cutover" + ], + "steps": [ + { + "description": "Open the mounted integration activity route.", + "action": "navigate", + "target": "/setup/integrations/activity", + "expected": "The route renders Integration Activity with audit-backed lifecycle context.", + "result": "pass", + "stepCapturedAtUtc": "2026-04-15T17:03:18Z" + }, + { + "description": "Verify the removed development feed placeholder text is absent.", + "action": "assert", + "target": "Integration Activity page body", + "expected": "\"Mock data for development\" does not appear on the mounted route.", + "result": "pass", + "stepCapturedAtUtc": "2026-04-15T17:03:18Z" + } + ], + "evidence": "docs/qa/feature-checks/runs/web/integration-hub-ui/run-002/evidence/playwright-ui-evidence.txt", + "verdict": "pass" +} diff --git a/docs/qa/feature-checks/runs/web/offline-kit-ui-integration/run-002/evidence/playwright-ui-evidence.txt b/docs/qa/feature-checks/runs/web/offline-kit-ui-integration/run-002/evidence/playwright-ui-evidence.txt new file mode 100644 index 000000000..979cd13ab --- /dev/null +++ b/docs/qa/feature-checks/runs/web/offline-kit-ui-integration/run-002/evidence/playwright-ui-evidence.txt @@ -0,0 +1,15 @@ +Tier2 strict UI evidence for offline-kit-ui-integration +CapturedAtUtc: 2026-04-15T17:03:18Z +Playwright command: PLAYWRIGHT_BASE_URL=https://stella-ops.local npx playwright test --config playwright.config.ts tests/e2e/ui-truthful-state-cutover.recheck.spec.ts --workers=1 --reporter=list +Playwright test mapping: rechecks mounted truthful-state routes after the mock-data cutover +Route: /ops/operations/offline-kit/bundles +Assertions: +- heading "Bundle Management" is visible +- "Load, verify, and manage offline bundles for air-gapped operation" is visible +- removed seeded bundle placeholder text is absent +Command output snippet: +- Running 2 tests using 1 worker +- ✓ rechecks mounted truthful-state routes after the mock-data cutover (22.4s) +- ✓ rechecks policy governance conflicts without fabricated preview content (11.2s) +- 2 passed (34.9s) +Result: pass diff --git a/docs/qa/feature-checks/runs/web/offline-kit-ui-integration/run-002/tier2-ui-check.json b/docs/qa/feature-checks/runs/web/offline-kit-ui-integration/run-002/tier2-ui-check.json new file mode 100644 index 000000000..abd6997e9 --- /dev/null +++ b/docs/qa/feature-checks/runs/web/offline-kit-ui-integration/run-002/tier2-ui-check.json @@ -0,0 +1,32 @@ +{ + "type": "ui", + "module": "web", + "feature": "offline-kit-ui-integration", + "runId": "run-002", + "baseUrl": "https://stella-ops.local", + "capturedAtUtc": "2026-04-15T17:03:18Z", + "playwrightSpec": "src/Web/StellaOps.Web/tests/e2e/ui-truthful-state-cutover.recheck.spec.ts", + "playwrightTests": [ + "rechecks mounted truthful-state routes after the mock-data cutover" + ], + "steps": [ + { + "description": "Open the mounted bundle management route.", + "action": "navigate", + "target": "/ops/operations/offline-kit/bundles", + "expected": "The route renders Bundle Management with the live offline-bundle workflow.", + "result": "pass", + "stepCapturedAtUtc": "2026-04-15T17:03:18Z" + }, + { + "description": "Verify seeded offline bundle placeholder text is absent.", + "action": "assert", + "target": "Bundle Management page body", + "expected": "\"Mock data - in production, load from IndexedDB or cache\" does not appear on the mounted route.", + "result": "pass", + "stepCapturedAtUtc": "2026-04-15T17:03:18Z" + } + ], + "evidence": "docs/qa/feature-checks/runs/web/offline-kit-ui-integration/run-002/evidence/playwright-ui-evidence.txt", + "verdict": "pass" +} diff --git a/docs/qa/feature-checks/runs/web/policy-governance-controls-ui/run-002/evidence/playwright-ui-evidence.txt b/docs/qa/feature-checks/runs/web/policy-governance-controls-ui/run-002/evidence/playwright-ui-evidence.txt new file mode 100644 index 000000000..d16ab4e80 --- /dev/null +++ b/docs/qa/feature-checks/runs/web/policy-governance-controls-ui/run-002/evidence/playwright-ui-evidence.txt @@ -0,0 +1,16 @@ +Tier2 strict UI evidence for policy-governance-controls-ui +CapturedAtUtc: 2026-04-15T17:03:18Z +Playwright command: PLAYWRIGHT_BASE_URL=https://stella-ops.local npx playwright test --config playwright.config.ts tests/e2e/ui-truthful-state-cutover.recheck.spec.ts --workers=1 --reporter=list +Playwright test mapping: rechecks policy governance conflicts without fabricated preview content +Route: /ops/policy/governance/conflicts +Assertions: +- heading "Policy Governance" is visible +- mounted conflict wizard advances into "Compare Conflicting Sources" +- the compare step shows metadata-only unavailable notices +- generated "condition" and "action" fields are absent +Command output snippet: +- Running 2 tests using 1 worker +- ✓ rechecks mounted truthful-state routes after the mock-data cutover (22.4s) +- ✓ rechecks policy governance conflicts without fabricated preview content (11.2s) +- 2 passed (34.9s) +Result: pass diff --git a/docs/qa/feature-checks/runs/web/policy-governance-controls-ui/run-002/tier2-ui-check.json b/docs/qa/feature-checks/runs/web/policy-governance-controls-ui/run-002/tier2-ui-check.json new file mode 100644 index 000000000..c8bcfc98a --- /dev/null +++ b/docs/qa/feature-checks/runs/web/policy-governance-controls-ui/run-002/tier2-ui-check.json @@ -0,0 +1,40 @@ +{ + "type": "ui", + "module": "web", + "feature": "policy-governance-controls-ui", + "runId": "run-002", + "baseUrl": "https://stella-ops.local", + "capturedAtUtc": "2026-04-15T17:03:18Z", + "playwrightSpec": "src/Web/StellaOps.Web/tests/e2e/ui-truthful-state-cutover.recheck.spec.ts", + "playwrightTests": [ + "rechecks policy governance conflicts without fabricated preview content" + ], + "steps": [ + { + "description": "Open the mounted policy governance conflicts route.", + "action": "navigate", + "target": "/ops/policy/governance/conflicts", + "expected": "The route renders Policy Governance and either an empty conflicts state or a live conflict list.", + "result": "pass", + "stepCapturedAtUtc": "2026-04-15T17:03:18Z" + }, + { + "description": "Open a live conflict resolution flow and advance into the compare step.", + "action": "click+assert", + "target": "First Resolve link and wizard Next button", + "expected": "Conflict Resolution Wizard reaches Compare Conflicting Sources on the mounted route.", + "result": "pass", + "stepCapturedAtUtc": "2026-04-15T17:03:18Z" + }, + { + "description": "Verify metadata-only preview messaging and the absence of fabricated rule bodies.", + "action": "assert", + "target": "Conflict Resolution Wizard compare step", + "expected": "Metadata-only unavailable notices render and generated \"condition\" / \"action\" JSON fields are absent.", + "result": "pass", + "stepCapturedAtUtc": "2026-04-15T17:03:18Z" + } + ], + "evidence": "docs/qa/feature-checks/runs/web/policy-governance-controls-ui/run-002/evidence/playwright-ui-evidence.txt", + "verdict": "pass" +} diff --git a/docs/qa/feature-checks/runs/web/topology-trust-administration-ui/run-002/evidence/playwright-ui-evidence.txt b/docs/qa/feature-checks/runs/web/topology-trust-administration-ui/run-002/evidence/playwright-ui-evidence.txt new file mode 100644 index 000000000..f19d88808 --- /dev/null +++ b/docs/qa/feature-checks/runs/web/topology-trust-administration-ui/run-002/evidence/playwright-ui-evidence.txt @@ -0,0 +1,15 @@ +Tier2 strict UI evidence for topology-trust-administration-ui +CapturedAtUtc: 2026-04-15T17:03:18Z +Playwright command: PLAYWRIGHT_BASE_URL=https://stella-ops.local npx playwright test --config playwright.config.ts tests/e2e/ui-truthful-state-cutover.recheck.spec.ts --workers=1 --reporter=list +Playwright test mapping: rechecks mounted truthful-state routes after the mock-data cutover +Route: /setup/topology/overview +Assertions: +- view-mode radio "Command" is visible +- view-mode radio "Topology" is visible +- removed fallback text is absent +Command output snippet: +- Running 2 tests using 1 worker +- ✓ rechecks mounted truthful-state routes after the mock-data cutover (22.4s) +- ✓ rechecks policy governance conflicts without fabricated preview content (11.2s) +- 2 passed (34.9s) +Result: pass diff --git a/docs/qa/feature-checks/runs/web/topology-trust-administration-ui/run-002/tier2-ui-check.json b/docs/qa/feature-checks/runs/web/topology-trust-administration-ui/run-002/tier2-ui-check.json new file mode 100644 index 000000000..b0403c50e --- /dev/null +++ b/docs/qa/feature-checks/runs/web/topology-trust-administration-ui/run-002/tier2-ui-check.json @@ -0,0 +1,32 @@ +{ + "type": "ui", + "module": "web", + "feature": "topology-trust-administration-ui", + "runId": "run-002", + "baseUrl": "https://stella-ops.local", + "capturedAtUtc": "2026-04-15T17:03:18Z", + "playwrightSpec": "src/Web/StellaOps.Web/tests/e2e/ui-truthful-state-cutover.recheck.spec.ts", + "playwrightTests": [ + "rechecks mounted truthful-state routes after the mock-data cutover" + ], + "steps": [ + { + "description": "Open the mounted topology overview route.", + "action": "navigate", + "target": "/setup/topology/overview", + "expected": "The route renders the topology overview shell with live Command and Topology view-mode radios.", + "result": "pass", + "stepCapturedAtUtc": "2026-04-15T17:03:18Z" + }, + { + "description": "Verify the removed fallback layout copy is absent.", + "action": "assert", + "target": "Topology overview page body", + "expected": "\"Mock topology layout for when API returns empty\" does not appear on the mounted route.", + "result": "pass", + "stepCapturedAtUtc": "2026-04-15T17:03:18Z" + } + ], + "evidence": "docs/qa/feature-checks/runs/web/topology-trust-administration-ui/run-002/evidence/playwright-ui-evidence.txt", + "verdict": "pass" +} diff --git a/docs/quickstart.md b/docs/quickstart.md index b857e59fb..255f4d137 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -135,7 +135,7 @@ docker compose -f devops/compose/docker-compose.stella-ops.yml --profile sigstor | `stella-ops.local` not found | Hosts entries missing | Re-run setup and accept hosts installation, or append `devops/compose/hosts.stellaops.local` manually | | `health=starting` for RustFS during setup | Advisory startup lag | Wait 30-60 seconds and re-check `docker compose ... ps` | | `stellaops-dev-rekor` restarting without `--profile sigstore` | Optional profile container from older runs | Non-blocking for default setup; ignore or clean old container | -| `SM remote service probe failed (localhost:56080)` in CLI | Optional China SM Remote plugin probe | Non-blocking for default crypto profile | +| `SM remote service probe failed (localhost:56080)` in `stella --verbose ...` or crypto diagnostics | Optional China SM Remote plugin probe | Non-blocking for default crypto profile; ordinary CLI payload commands now suppress this startup noise | | `admin seed-demo --confirm` fails with `scheduler_exceptions_tenant_isolation already exists` | Outdated Scheduler migration scripts | Pull latest code and rerun seeding | | Seed endpoint still returns HTTP 500 after patching source | Running old container image | Rebuild/restart platform image and retest | | Port conflicts | Local process already using mapped port | Override in `devops/compose/.env` (`devops/compose/env/stellaops.env.example`) | diff --git a/docs/setup/setup-wizard-capabilities.md b/docs/setup/setup-wizard-capabilities.md index 6cb3c2e3b..26a017bc7 100644 --- a/docs/setup/setup-wizard-capabilities.md +++ b/docs/setup/setup-wizard-capabilities.md @@ -7,9 +7,9 @@ This document defines the functional requirements for the Stella Ops Setup Wizar The Setup Wizard provides a guided, step-by-step configuration experience that: - Validates infrastructure dependencies (PostgreSQL, Valkey) - Runs database migrations -- Configures required integrations -- Sets up environments and agents -- Verifies each step via Doctor checks +- Bootstraps the initial admin and crypto profile +- Exposes a truthful required-readiness summary for setup completion +- Hands tenant onboarding to authenticated `/setup/*` and integration command surfaces instead of pretending they are bootstrap steps --- @@ -27,7 +27,7 @@ The system enters "Operational" state when: | Admin user exists | At least one admin user with `admin:*` scope | `check.auth.admin.exists` | | Crypto profile valid | At least one signing key configured | `check.crypto.profile.valid` | -**Gating Behavior:** UI blocks access to operational features until Operational threshold met. +**Gating Behavior:** Setup status and finalize gate only on this operational threshold. Optional post-boot services may still be degraded and are surfaced through health diagnostics instead of blocking bootstrap completion. ### 2.2 Production-Ready (Recommended) @@ -58,7 +58,9 @@ The system reaches "Production-Ready" state when: | `admin` | Admin Bootstrap | Yes | No | Security | | `crypto` | Crypto Profile | Yes | No | Security | -### 3.2 Integration Steps +Only these five core steps are current runtime setup step IDs. The integration and orchestration catalogs below are historical handoff targets and are no longer accepted by the current setup APIs or `stella setup` command group. + +### 3.2 Integration Handoffs (Not current setup steps) | Step ID | Name | Required | Skippable | Category | |---------|------|----------|-----------|----------| @@ -69,7 +71,7 @@ The system reaches "Production-Ready" state when: | `notifications` | Notification Channels | No | Yes | Integration | | `identity` | Identity Provider (OIDC/LDAP) | No | Yes | Security | -### 3.3 Orchestration Steps +### 3.3 Orchestration Handoffs (Not current setup steps) | Step ID | Name | Required | Skippable | Category | |---------|------|----------|-----------|----------| @@ -81,6 +83,8 @@ The system reaches "Production-Ready" state when: ## 4. Step Specifications +Sections 4.1-4.5 describe the current installation-scoped setup steps. Sections 4.6 and later remain useful as onboarding capability notes, but those inputs now belong to authenticated post-bootstrap surfaces rather than the setup wizard step catalog. + ### 4.1 Database Setup (`database`) **Purpose:** Configure PostgreSQL connection and verify accessibility. diff --git a/docs/setup/setup-wizard-ux.md b/docs/setup/setup-wizard-ux.md index 8b3be3310..502f418d7 100644 --- a/docs/setup/setup-wizard-ux.md +++ b/docs/setup/setup-wizard-ux.md @@ -39,6 +39,11 @@ design material later in this document. defaults into wizard draft state that it renders in the visible form, so an operator can accept the prefilled values without retyping them and still get a truthful backend apply. +- Session reads now separate sanitized `draftValues` from `secretDrafts` + metadata. Secret-bearing inputs such as admin or database passwords are + retained only in protected server-side companion storage, can survive resume, + and are rendered as retained placeholders rather than round-tripping + plaintext back into the browser or CLI. - `probe` and `apply` are now distinct backend operations. Successful probes do not complete steps. - `stella setup` is a backend-authoritative client for the same diff --git a/src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs b/src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs index ca479a46b..2c971c998 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs @@ -377,6 +377,18 @@ public static class StellaOpsScopes /// public const string OrchBackfill = "orch:backfill"; + /// + /// Scope granting read-only access to the scripts registry + /// (list, get, version history, validate, compatibility checks). + /// + public const string ScriptRead = "script:read"; + + /// + /// Scope granting mutation access to the scripts registry + /// (create, update, delete). + /// + public const string ScriptWrite = "script:write"; + /// /// Scope granting read-only access to Authority tenant catalog APIs. /// diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginBootstrapperTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginBootstrapperTests.cs index 86efaf65b..e68acd316 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginBootstrapperTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginBootstrapperTests.cs @@ -26,7 +26,7 @@ public class StandardPluginBootstrapperTests { [Trait("Category", TestCategories.Unit)] [Fact] - public async Task StartAsync_EnsuresBootstrapClientsWithoutBootstrapUser() + public async Task StartAsync_EnsuresUiAndCliBootstrapClientsWithoutBootstrapUser() { var services = new ServiceCollection(); services.AddOptions("standard") @@ -45,6 +45,25 @@ public class StandardPluginBootstrapperTests RedirectUris = "https://stella-ops.local/auth/callback https://stella-ops.local/auth/silent-refresh", PostLogoutRedirectUris = "https://stella-ops.local/", RequirePkce = true + }, + new BootstrapClientOptions + { + ClientId = "stellaops-cli", + DisplayName = "Stella Ops CLI", + AllowedGrantTypes = "authorization_code password refresh_token", + AllowedScopes = $"openid profile offline_access {StellaOpsScopes.ConcelierJobsTrigger} {StellaOpsScopes.UiAdmin}", + RedirectUris = "http://127.0.0.1:8400/callback http://localhost:8400/callback", + PostLogoutRedirectUris = "http://127.0.0.1:8400/logout http://localhost:8400/logout", + RequirePkce = true + }, + new BootstrapClientOptions + { + ClientId = "stellaops-cli-automation", + DisplayName = "Stella Ops CLI Automation", + Confidential = true, + ClientSecret = "stellaops-local-cli-automation-secret", + AllowedGrantTypes = "client_credentials", + AllowedScopes = $"{StellaOpsScopes.ConcelierJobsTrigger} {StellaOpsScopes.OpsHealth}" } }; }); @@ -82,6 +101,22 @@ public class StandardPluginBootstrapperTests Assert.Contains("authorization_code", client.AllowedGrantTypes); Assert.True(client.RequirePkce); Assert.Equal("demo-prod", client.Properties[AuthorityClientMetadataKeys.Tenant]); + + var humanCliClient = await clientStore.FindByClientIdAsync("stellaops-cli", TestContext.Current.CancellationToken); + Assert.NotNull(humanCliClient); + Assert.False(humanCliClient!.RequireClientSecret); + Assert.Contains("password", humanCliClient.AllowedGrantTypes); + Assert.Contains("authorization_code", humanCliClient.AllowedGrantTypes); + Assert.Contains(StellaOpsScopes.ConcelierJobsTrigger, humanCliClient.AllowedScopes); + Assert.Contains(StellaOpsScopes.UiAdmin, humanCliClient.AllowedScopes); + Assert.True(humanCliClient.RequirePkce); + + var automationCliClient = await clientStore.FindByClientIdAsync("stellaops-cli-automation", TestContext.Current.CancellationToken); + Assert.NotNull(automationCliClient); + Assert.True(automationCliClient!.RequireClientSecret); + Assert.Contains("client_credentials", automationCliClient.AllowedGrantTypes); + Assert.Contains(StellaOpsScopes.ConcelierJobsTrigger, automationCliClient.AllowedScopes); + Assert.Contains(StellaOpsScopes.OpsHealth, automationCliClient.AllowedScopes); } [Trait("Category", TestCategories.Unit)] diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginOptionsTests.cs b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginOptionsTests.cs index 2be943ee9..527e1f4be 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginOptionsTests.cs +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StandardPluginOptionsTests.cs @@ -1,7 +1,9 @@ using System; using System.IO; +using Microsoft.Extensions.Configuration; using StellaOps.Auth.Abstractions; using StellaOps.Authority.Plugin.Standard; +using StellaOps.Configuration; using StellaOps.Cryptography; using StellaOps.TestKit; @@ -50,6 +52,47 @@ public class StandardPluginOptionsTests options.Validate("standard"); } + [Trait("Category", TestCategories.Unit)] + [Fact] + public void RepositoryStandardYaml_DefinesFirstPartyCliBootstrapClients() + { + var configPath = ResolveRepositoryFile("etc/authority/plugins/standard.yaml"); + var authorityOptions = new StellaOpsAuthorityOptions(); + authorityOptions.Plugins.ConfigurationDirectory = Path.GetDirectoryName(configPath)!; + authorityOptions.Plugins.Descriptors["standard"] = new AuthorityPluginDescriptorOptions + { + Type = "standard", + AssemblyName = "StellaOps.Authority.Plugin.Standard", + ConfigFile = Path.GetFileName(configPath), + Enabled = true + }; + + var pluginContext = Assert.Single(AuthorityPluginConfigurationLoader.Load(authorityOptions, AppContext.BaseDirectory)); + + var options = new StandardPluginOptions(); + pluginContext.Configuration.Bind(options); + options.Normalize(configPath); + options.Validate("standard"); + + var clients = options.BootstrapClients.ToDictionary(client => client.ClientId!, StringComparer.Ordinal); + + Assert.Contains("stella-ops-ui", clients.Keys); + Assert.Contains("stellaops-cli", clients.Keys); + Assert.Contains("stellaops-cli-automation", clients.Keys); + + var humanCliClient = clients["stellaops-cli"]; + Assert.False(humanCliClient.Confidential); + Assert.Equal("authorization_code password refresh_token", humanCliClient.AllowedGrantTypes); + Assert.Contains(StellaOpsScopes.ConcelierJobsTrigger, humanCliClient.AllowedScopes!, StringComparison.Ordinal); + Assert.Contains("http://127.0.0.1:8400/callback", humanCliClient.RedirectUris!, StringComparison.Ordinal); + + var automationCliClient = clients["stellaops-cli-automation"]; + Assert.True(automationCliClient.Confidential); + Assert.Equal("client_credentials", automationCliClient.AllowedGrantTypes); + Assert.Equal("stellaops-local-cli-automation-secret", automationCliClient.ClientSecret); + Assert.Contains(StellaOpsScopes.OpsHealth, automationCliClient.AllowedScopes!, StringComparison.Ordinal); + } + [Trait("Category", TestCategories.Unit)] [Fact] public void Validate_Throws_WhenBootstrapUserIncomplete() @@ -247,4 +290,21 @@ public class StandardPluginOptionsTests var ex = Assert.Throws(() => options.Validate("standard")); Assert.Contains("parallelism", ex.Message, StringComparison.OrdinalIgnoreCase); } + + private static string ResolveRepositoryFile(string relativePath) + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + var candidate = Path.Combine(current.FullName, relativePath); + if (File.Exists(candidate)) + { + return candidate; + } + + current = current.Parent; + } + + throw new FileNotFoundException($"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } } diff --git a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj index 058e1c652..9cea8fe43 100644 --- a/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj +++ b/src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj @@ -12,7 +12,8 @@ + - \ No newline at end of file + diff --git a/src/Cli/StellaOps.Cli/Commands/Admin/AdminCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/Admin/AdminCommandGroup.cs index 2ed77fd4d..88c68dfe7 100644 --- a/src/Cli/StellaOps.Cli/Commands/Admin/AdminCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/Admin/AdminCommandGroup.cs @@ -2,7 +2,10 @@ // Sprint: SPRINT_4100_0006_0005 - Admin Utility Integration using System.CommandLine; +using System.Linq; +using System.Text.Json; using StellaOps.Cli.Services; +using StellaOps.Cli.Services.Models; using StellaOps.Infrastructure.Postgres.Migrations; using StellaOps.Platform.Database; using Microsoft.Extensions.DependencyInjection; @@ -16,6 +19,12 @@ namespace StellaOps.Cli.Commands.Admin; /// internal static class AdminCommandGroup { + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + /// /// Build the admin command group with policy/users/feeds/system subcommands. /// @@ -35,7 +44,7 @@ internal static class AdminCommandGroup // Sprint: SPRINT_20260118_014_CLI_evidence_remaining_consolidation (CLI-E-005) admin.Add(BuildTenantsCommand(verboseOption)); admin.Add(BuildAuditCommand(verboseOption)); - admin.Add(BuildDiagnosticsCommand(verboseOption)); + admin.Add(BuildDiagnosticsCommand(services, verboseOption)); // Demo data seeding admin.Add(BuildSeedDemoCommand(services, verboseOption, cancellationToken)); @@ -680,28 +689,45 @@ internal static class AdminCommandGroup /// Build the 'admin diagnostics' command. /// Moved from stella diagnostics /// - private static Command BuildDiagnosticsCommand(Option verboseOption) + private static Command BuildDiagnosticsCommand(IServiceProvider services, Option verboseOption) { var diagnostics = new Command("diagnostics", "System diagnostics (from: diagnostics)."); // admin diagnostics health var health = new Command("health", "Run health checks."); var detailOption = new Option("--detail") { Description = "Show detailed results" }; + var formatOption = new Option("--format") + { + Description = "Output format: table, json" + }; + formatOption.SetDefaultValue("table"); health.Add(detailOption); - health.SetAction((parseResult, _) => + health.Add(formatOption); + health.SetAction(async (parseResult, ct) => { var detail = parseResult.GetValue(detailOption); - Console.WriteLine("Health Check Results"); - Console.WriteLine("===================="); - Console.WriteLine("CHECK STATUS LATENCY"); - Console.WriteLine("Database OK 12ms"); - Console.WriteLine("Redis Cache OK 3ms"); - Console.WriteLine("Scanner Service OK 45ms"); - Console.WriteLine("Feed Sync Service OK 23ms"); - Console.WriteLine("HSM Connection OK 8ms"); - Console.WriteLine(); - Console.WriteLine("Overall: HEALTHY"); - return Task.FromResult(0); + var format = parseResult.GetValue(formatOption) ?? "table"; + var backend = services.GetRequiredService(); + + try + { + var readiness = await backend.GetPlatformReadinessAsync(ct).ConfigureAwait(false); + if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine(JsonSerializer.Serialize(readiness, JsonOptions)); + } + else + { + RenderHealthDiagnostics(readiness, detail); + } + + return 0; + } + catch (Exception ex) + { + Console.WriteLine($"Health diagnostics failed: {ex.Message}"); + return 1; + } }); // admin diagnostics connectivity @@ -747,5 +773,68 @@ internal static class AdminCommandGroup return diagnostics; } + private static void RenderHealthDiagnostics(BackendPlatformReadiness readiness, bool detail) + { + Console.WriteLine("Platform readiness"); + Console.WriteLine("=================="); + Console.WriteLine($"Overall: {NormalizeReadinessStatus(readiness.Status).ToUpperInvariant()}"); + Console.WriteLine($"Summary: {readiness.Message}"); + Console.WriteLine($"Required ready: {readiness.RequiredReadyCount}/{readiness.RequiredDependencyCount}"); + if (readiness.OptionalDependencyCount > 0) + { + Console.WriteLine($"Optional ready: {readiness.OptionalReadyCount}/{readiness.OptionalDependencyCount}"); + } + + Console.WriteLine(); + RenderHealthSection("Required", readiness.Dependencies.Where(dependency => dependency.Required), detail); + + var optional = readiness.Dependencies.Where(dependency => !dependency.Required).ToArray(); + if (optional.Length > 0) + { + Console.WriteLine(); + RenderHealthSection("Optional", optional, detail); + } + } + + private static void RenderHealthSection(string title, IEnumerable dependencies, bool detail) + { + var materialized = dependencies + .OrderBy(dependency => dependency.DisplayName, StringComparer.Ordinal) + .ToArray(); + + Console.WriteLine(title); + Console.WriteLine("SERVICE STATUS DETAILS"); + foreach (var dependency in materialized) + { + var details = dependency.Message ?? string.Empty; + if (detail && !string.IsNullOrWhiteSpace(dependency.Endpoint)) + { + details = string.IsNullOrWhiteSpace(details) + ? dependency.Endpoint + : $"{details} [{dependency.Endpoint}]"; + } + + Console.WriteLine($"{TrimToWidth(dependency.DisplayName, 26),-26} {NormalizeReadinessStatus(dependency.Status).ToUpperInvariant(),-10} {details}"); + } + } + + private static string NormalizeReadinessStatus(string? status) => + string.IsNullOrWhiteSpace(status) ? "unknown" : status.Trim().ToLowerInvariant(); + + private static string TrimToWidth(string value, int width) + { + if (string.IsNullOrEmpty(value) || value.Length <= width) + { + return value; + } + + if (width <= 3) + { + return value[..width]; + } + + return value[..(width - 3)] + "..."; + } + #endregion } diff --git a/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs b/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs index d1be52400..a0d1b6df1 100644 --- a/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs +++ b/src/Cli/StellaOps.Cli/Commands/CommandFactory.cs @@ -2036,7 +2036,7 @@ internal static class CommandFactory { var auth = new Command("auth", "Manage authentication with StellaOps Authority."); - var login = new Command("login", "Acquire and cache access tokens using the configured credentials."); + var login = new Command("login", "Acquire and cache access tokens. The seeded human client prompts for username/password; automation uses configured client credentials."); var forceOption = new Option("--force") { Description = "Ignore existing cached tokens and force re-authentication." diff --git a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs index ac1073ce0..d686fd7d8 100644 --- a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs +++ b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs @@ -63,6 +63,8 @@ namespace StellaOps.Cli.Commands; internal static partial class CommandHandlers { private const string KmsPassphraseEnvironmentVariable = "STELLAOPS_KMS_PASSPHRASE"; + private const string AuthCacheGrantTypeMetadataKey = "grant_type"; + private const string AuthCacheUsernameMetadataKey = "username"; private static readonly JsonSerializerOptions KmsJsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true @@ -102,6 +104,102 @@ internal static partial class CommandHandlers } } + private static bool UsesDefaultHumanAuthorityClient(StellaOpsCliOptions options) + => string.Equals( + options.Authority?.ClientId, + StellaOpsCliAuthorityOptions.DefaultHumanClientId, + StringComparison.Ordinal); + + private static string? ResolveCachedGrantType(StellaOpsCliOptions options, StellaOpsTokenCacheEntry entry) + { + if (entry.Metadata is not null + && entry.Metadata.TryGetValue(AuthCacheGrantTypeMetadataKey, out var cachedGrantType) + && !string.IsNullOrWhiteSpace(cachedGrantType)) + { + return cachedGrantType; + } + + return string.IsNullOrWhiteSpace(options.Authority?.Username) ? "client_credentials" : "password"; + } + + private static StellaOpsTokenCacheEntry BuildAuthCacheEntry( + StellaOpsTokenResult token, + string grantType, + string? username = null) + { + var metadata = new Dictionary(StringComparer.Ordinal) + { + [AuthCacheGrantTypeMetadataKey] = grantType + }; + + if (!string.IsNullOrWhiteSpace(username)) + { + metadata[AuthCacheUsernameMetadataKey] = username.Trim(); + } + + return token.ToCacheEntry() with { Metadata = metadata }; + } + + private static bool TryResolvePasswordGrantCredentials( + ILogger logger, + StellaOpsCliOptions options, + out string username, + out string password) + { + username = options.Authority?.Username?.Trim() ?? string.Empty; + password = options.Authority?.Password?.Trim() ?? string.Empty; + + if (!string.IsNullOrWhiteSpace(username)) + { + if (string.IsNullOrWhiteSpace(password)) + { + logger.LogError("Authority password must be provided when username is configured."); + return false; + } + + return true; + } + + if (!UsesDefaultHumanAuthorityClient(options)) + { + logger.LogError( + "Authority username is not configured. Set STELLAOPS_AUTHORITY_USERNAME and STELLAOPS_AUTHORITY_PASSWORD, or use the automation client '{AutomationClientId}' with a client secret.", + StellaOpsCliAuthorityOptions.DefaultAutomationClientId); + return false; + } + + var console = AnsiConsole.Console; + if (!console.Profile.Capabilities.Interactive) + { + logger.LogError( + "The default human CLI client '{ClientId}' needs interactive username/password entry in a fresh shell. Set STELLAOPS_AUTHORITY_USERNAME and STELLAOPS_AUTHORITY_PASSWORD, or switch to '{AutomationClientId}' with STELLAOPS_AUTHORITY_CLIENT_SECRET for automation.", + StellaOpsCliAuthorityOptions.DefaultHumanClientId, + StellaOpsCliAuthorityOptions.DefaultAutomationClientId); + return false; + } + + username = AnsiConsole.Prompt( + new TextPrompt("Authority username:") + .PromptStyle("green") + .Validate(static value => + string.IsNullOrWhiteSpace(value) + ? Spectre.Console.ValidationResult.Error("[red]Username is required.[/]") + : Spectre.Console.ValidationResult.Success())); + + password = AnsiConsole.Prompt( + new TextPrompt($"Authority password for {Markup.Escape(username.Trim())}:") + .PromptStyle("green") + .Secret() + .Validate(static value => + string.IsNullOrWhiteSpace(value) + ? Spectre.Console.ValidationResult.Error("[red]Password is required.[/]") + : Spectre.Console.ValidationResult.Success())); + + username = username.Trim(); + password = password.Trim(); + return true; + } + public static async Task HandleCvssScoreAsync( IServiceProvider services, string vulnerabilityId, @@ -2292,29 +2390,32 @@ internal static partial class CommandHandlers var scopeName = AuthorityTokenUtilities.ResolveScope(options); StellaOpsTokenResult token; + StellaOpsTokenCacheEntry cacheEntry; + var usePasswordGrant = !string.IsNullOrWhiteSpace(options.Authority.Username) || UsesDefaultHumanAuthorityClient(options); - if (!string.IsNullOrWhiteSpace(options.Authority.Username)) + if (usePasswordGrant) { - if (string.IsNullOrWhiteSpace(options.Authority.Password)) + if (!TryResolvePasswordGrantCredentials(logger, options, out var username, out var password)) { - logger.LogError("Authority password must be provided when username is configured."); Environment.ExitCode = 1; return; } token = await tokenClient.RequestPasswordTokenAsync( - options.Authority.Username, - options.Authority.Password!, + username, + password, scopeName, null, cancellationToken).ConfigureAwait(false); + cacheEntry = BuildAuthCacheEntry(token, grantType: "password", username); } else { token = await tokenClient.RequestClientCredentialsTokenAsync(scopeName, null, cancellationToken).ConfigureAwait(false); + cacheEntry = BuildAuthCacheEntry(token, grantType: "client_credentials"); } - await tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false); + await tokenClient.CacheTokenAsync(cacheKey, cacheEntry, cancellationToken).ConfigureAwait(false); if (verbose) { @@ -2405,6 +2506,12 @@ internal static partial class CommandHandlers logger.LogInformation("Cached token for {Authority} expires at {Expires}.", options.Authority.Url, entry.ExpiresAtUtc.ToString("u")); if (verbose) { + var grantType = ResolveCachedGrantType(options, entry); + if (!string.IsNullOrWhiteSpace(grantType)) + { + logger.LogInformation("Grant type: {GrantType}", grantType); + } + logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes)); } } @@ -2450,7 +2557,7 @@ internal static partial class CommandHandlers return; } - var grantType = string.IsNullOrWhiteSpace(options.Authority.Username) ? "client_credentials" : "password"; + var grantType = ResolveCachedGrantType(options, entry); var now = DateTimeOffset.UtcNow; var remaining = entry.ExpiresAtUtc - now; if (remaining < TimeSpan.Zero) @@ -2459,10 +2566,20 @@ internal static partial class CommandHandlers } logger.LogInformation("Authority: {Authority}", options.Authority.Url); - logger.LogInformation("Grant type: {GrantType}", grantType); + if (!string.IsNullOrWhiteSpace(grantType)) + { + logger.LogInformation("Grant type: {GrantType}", grantType); + } logger.LogInformation("Token type: {TokenType}", entry.TokenType); logger.LogInformation("Expires: {Expires} ({Remaining})", entry.ExpiresAtUtc.ToString("u"), FormatDuration(remaining)); + if (entry.Metadata is not null + && entry.Metadata.TryGetValue(AuthCacheUsernameMetadataKey, out var cachedUsername) + && !string.IsNullOrWhiteSpace(cachedUsername)) + { + logger.LogInformation("Username: {Username}", cachedUsername); + } + if (entry.Scopes.Count > 0) { logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes)); diff --git a/src/Cli/StellaOps.Cli/Commands/IntegrationsCommandGroup.cs b/src/Cli/StellaOps.Cli/Commands/IntegrationsCommandGroup.cs index 31f069c99..ff28af099 100644 --- a/src/Cli/StellaOps.Cli/Commands/IntegrationsCommandGroup.cs +++ b/src/Cli/StellaOps.Cli/Commands/IntegrationsCommandGroup.cs @@ -4,6 +4,7 @@ using StellaOps.Integrations.Contracts; using StellaOps.Integrations.Core; using System.CommandLine; using System.Globalization; +using System.Net.Http; using System.Text.Json; using System.Text.Json.Serialization; @@ -34,6 +35,7 @@ internal static class IntegrationsCommandGroup integrations.Add(BuildHealthCommand(services, verboseOption, cancellationToken)); integrations.Add(BuildImpactCommand(services, verboseOption, cancellationToken)); integrations.Add(BuildDiscoverCommand(services, verboseOption, cancellationToken)); + integrations.Add(BuildBootstrapCommand(services, verboseOption, cancellationToken)); integrations.Add(BuildSecretsCommand(services, verboseOption, cancellationToken)); return integrations; @@ -564,6 +566,61 @@ internal static class IntegrationsCommandGroup return discover; } + private static Command BuildBootstrapCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) + { + var formatOption = BuildFormatOption(); + var includeGitLabOption = new Option("--include-gitlab") + { + Description = "Include the local GitLab SCM and CI fixtures. The CLI will mint and stage the local compose PAT into Vault automatically." + }; + var includeGitLabRegistryOption = new Option("--include-gitlab-registry") + { + Description = "Include the optional local GitLab container registry fixture. Requires the heavy GitLab compose profile with registry enabled." + }; + + var bootstrap = new Command("bootstrap", "Bootstrap deterministic integration catalogs for Stella-owned fixture environments."); + var local = new Command("local", "Create or update the local compose fixture catalog, stage required secrets, and verify the resulting health."); + local.Add(includeGitLabOption); + local.Add(includeGitLabRegistryOption); + local.Add(formatOption); + local.Add(verboseOption); + + local.SetAction(async (parseResult, _) => + { + try + { + var backend = services.GetRequiredService(); + var httpClientFactory = services.GetService(); + var bootstrapper = new LocalIntegrationBootstrapper(backend, httpClientFactory); + var result = await bootstrapper.BootstrapAsync( + new LocalIntegrationBootstrapper.LocalIntegrationBootstrapRequest( + parseResult.GetValue(includeGitLabOption), + parseResult.GetValue(includeGitLabRegistryOption)), + cancellationToken).ConfigureAwait(false); + + var format = RequireFormat(parseResult.GetValue(formatOption)); + if (IsJson(format)) + { + WriteJson(result); + } + else + { + WriteLocalBootstrapResult(result); + } + + return result.AllHealthy ? 0 : 1; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error bootstrapping local integrations: {ex.Message}"); + return 1; + } + }); + + bootstrap.Add(local); + return bootstrap; + } + private static Command BuildSecretsCommand(IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var secrets = new Command("secrets", "Stage authref-backed secret bundles and inspect available secret-authority targets."); @@ -914,6 +971,50 @@ internal static class IntegrationsCommandGroup } } + private static void WriteLocalBootstrapResult(LocalIntegrationBootstrapper.LocalIntegrationBootstrapResult result) + { + Console.WriteLine("Local Integration Bootstrap"); + Console.WriteLine("==========================="); + Console.WriteLine(); + Console.WriteLine($"Mode: {result.Mode}"); + Console.WriteLine($"Profiles: {string.Join(", ", result.Profiles)}"); + Console.WriteLine($"Outcome: {(result.AllHealthy ? "healthy" : "attention-required")}"); + Console.WriteLine(); + + if (result.Secrets.Count > 0) + { + Console.WriteLine("Staged Secrets"); + Console.WriteLine("--------------"); + foreach (var secret in result.Secrets.OrderBy(item => item.SecretId, StringComparer.Ordinal)) + { + Console.WriteLine($"{secret.SecretId} bundle={secret.BundleId} generator={secret.Generator}"); + Console.WriteLine($" target={secret.TargetName} ({secret.TargetProvider}) path={secret.LogicalPath}"); + if (secret.AuthRefs.Count == 0) + { + Console.WriteLine(" authrefs=none"); + continue; + } + + Console.WriteLine($" authrefs={string.Join(", ", secret.AuthRefs.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}={pair.Value}"))}"); + } + + Console.WriteLine(); + } + + Console.WriteLine("Integrations"); + Console.WriteLine("------------"); + foreach (var integration in result.Integrations.OrderBy(item => item.Name, StringComparer.Ordinal)) + { + Console.WriteLine($"{integration.Name} [{integration.Action}]"); + Console.WriteLine($" provider={integration.Provider} type={integration.Type} auth={integration.HasAuth}"); + Console.WriteLine($" test={integration.TestSucceeded} health={integration.HealthStatus}"); + Console.WriteLine($" endpoint={integration.Endpoint}"); + } + + Console.WriteLine(); + Console.WriteLine(result.ProductionGuidance); + } + private static IReadOnlyList? NormalizeValues(string[]? values) { var normalized = (values ?? []) diff --git a/src/Cli/StellaOps.Cli/Commands/LocalIntegrationBootstrapper.cs b/src/Cli/StellaOps.Cli/Commands/LocalIntegrationBootstrapper.cs new file mode 100644 index 000000000..ca63797a5 --- /dev/null +++ b/src/Cli/StellaOps.Cli/Commands/LocalIntegrationBootstrapper.cs @@ -0,0 +1,682 @@ +using StellaOps.Cli.Services; +using StellaOps.Integrations.Contracts; +using StellaOps.Integrations.Core; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Net.Http.Headers; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StellaOps.Cli.Commands; + +internal sealed class LocalIntegrationBootstrapper +{ + private const string ManifestResourceName = "StellaOps.Cli.Commands.local-integration-bootstrap.manifest.json"; + // The owned local GitLab fixture does not expose registry-specific PAT scopes; api covers + // both the server/CI surfaces and registry basic-auth bootstrap in this compose lane. + private static readonly string[] LocalGitLabPersonalAccessTokenScopes = ["api"]; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + private readonly IBackendOperationsClient _backend; + private readonly IHttpClientFactory? _httpClientFactory; + + public LocalIntegrationBootstrapper(IBackendOperationsClient backend, IHttpClientFactory? httpClientFactory = null) + { + _backend = backend ?? throw new ArgumentNullException(nameof(backend)); + _httpClientFactory = httpClientFactory; + } + + public async Task BootstrapAsync( + LocalIntegrationBootstrapRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var manifest = LoadManifest(); + var selectedProfiles = BuildSelectedProfiles(request); + var selectedIntegrations = manifest.Integrations + .Where(definition => definition.Profiles.Any(selectedProfiles.Contains)) + .ToArray(); + + var existingByKey = await LoadExistingIntegrationsAsync(cancellationToken).ConfigureAwait(false); + var ensuredByManifestId = new Dictionary(StringComparer.OrdinalIgnoreCase); + var actionsByManifestId = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var definition in selectedIntegrations.Where(IsSecretAuthorityDefinition)) + { + var ensured = await EnsureIntegrationAsync(definition, existingByKey, authRefs: null, cancellationToken).ConfigureAwait(false); + ensuredByManifestId[definition.Id] = ensured.Response; + actionsByManifestId[definition.Id] = ensured.Action; + } + + var secretResults = await StageSecretsAsync( + manifest, + selectedProfiles, + ensuredByManifestId, + cancellationToken).ConfigureAwait(false); + + var authRefs = secretResults + .SelectMany(result => result.AuthRefs.Select(pair => new KeyValuePair(new AuthRefSourceKey(result.SecretId, pair.Key), pair.Value))) + .ToDictionary(pair => pair.Key, pair => pair.Value); + + foreach (var definition in selectedIntegrations.Where(definition => !IsSecretAuthorityDefinition(definition))) + { + var ensured = await EnsureIntegrationAsync(definition, existingByKey, authRefs, cancellationToken).ConfigureAwait(false); + ensuredByManifestId[definition.Id] = ensured.Response; + actionsByManifestId[definition.Id] = ensured.Action; + } + + var integrationResults = new List(selectedIntegrations.Length); + foreach (var definition in selectedIntegrations) + { + if (!ensuredByManifestId.TryGetValue(definition.Id, out var ensured)) + { + throw new InvalidOperationException($"Local integration bootstrap did not resolve manifest entry '{definition.Id}'."); + } + + var test = await _backend.TestIntegrationAsync(ensured.Id, cancellationToken).ConfigureAwait(false) + ?? new TestConnectionResponse( + ensured.Id, + Success: false, + Message: "The integration test endpoint returned no result.", + Details: null, + Duration: TimeSpan.Zero, + TestedAt: DateTimeOffset.UtcNow); + var health = await _backend.GetIntegrationHealthAsync(ensured.Id, cancellationToken).ConfigureAwait(false) + ?? new HealthCheckResponse( + ensured.Id, + HealthStatus.Unhealthy, + "The integration health endpoint returned no result.", + Details: null, + DateTimeOffset.UtcNow, + TimeSpan.Zero); + + integrationResults.Add(new LocalIntegrationBootstrapIntegrationResult( + definition.Id, + ensured.Id, + ensured.Name, + ensured.Provider, + ensured.Type, + actionsByManifestId[definition.Id], + ensured.Endpoint, + ensured.HasAuth, + test.Success, + test.Message, + health.Status, + health.Message)); + } + + var allHealthy = integrationResults.All(result => result.TestSucceeded && result.HealthStatus == HealthStatus.Healthy); + return new LocalIntegrationBootstrapResult( + manifest.Mode, + selectedProfiles.OrderBy(profile => profile, StringComparer.OrdinalIgnoreCase).ToArray(), + manifest.ProductionGuidance, + secretResults, + integrationResults, + allHealthy); + } + + private async Task> LoadExistingIntegrationsAsync(CancellationToken cancellationToken) + { + const int pageSize = 200; + + var existing = new Dictionary(); + var page = 1; + + while (true) + { + var response = await _backend.ListIntegrationsAsync( + type: null, + provider: null, + status: null, + search: null, + page: page, + pageSize: pageSize, + sortBy: "name", + sortDescending: false, + cancellationToken).ConfigureAwait(false); + + foreach (var item in response.Items) + { + existing[new IntegrationKey(item.Provider, NormalizeEndpoint(item.Endpoint))] = item; + } + + if (response.TotalPages <= page || response.Items.Count == 0) + { + break; + } + + page++; + } + + return existing; + } + + private async Task EnsureIntegrationAsync( + LocalIntegrationManifestDefinition definition, + IDictionary existingByKey, + IReadOnlyDictionary? authRefs, + CancellationToken cancellationToken) + { + var key = new IntegrationKey(definition.Provider, NormalizeEndpoint(definition.Endpoint)); + var authRefUri = ResolveAuthRef(definition, authRefs); + if (!existingByKey.TryGetValue(key, out var existing)) + { + var created = await _backend.CreateIntegrationAsync( + new CreateIntegrationRequest( + definition.Name, + definition.Description, + definition.Type, + definition.Provider, + definition.Endpoint, + authRefUri, + definition.OrganizationId, + ToConfigObjectDictionary(definition.Config), + definition.Tags), + cancellationToken).ConfigureAwait(false); + + existingByKey[key] = created; + return new EnsureIntegrationResult(created, "created"); + } + + if (!NeedsUpdate(existing, definition, authRefUri)) + { + return new EnsureIntegrationResult(existing, "existing"); + } + + var updated = await _backend.UpdateIntegrationAsync( + existing.Id, + new UpdateIntegrationRequest( + definition.Name, + definition.Description, + definition.Endpoint, + authRefUri, + definition.OrganizationId, + ToConfigObjectDictionary(definition.Config), + definition.Tags, + existing.Status is IntegrationStatus.Archived or IntegrationStatus.Disabled + ? IntegrationStatus.Pending + : null), + cancellationToken).ConfigureAwait(false); + + existingByKey[key] = updated; + return new EnsureIntegrationResult(updated, "updated"); + } + + private async Task> StageSecretsAsync( + LocalIntegrationBootstrapManifest manifest, + ISet selectedProfiles, + IReadOnlyDictionary ensuredByManifestId, + CancellationToken cancellationToken) + { + var selectedSecrets = manifest.Secrets + .Where(secret => secret.Profiles.Any(selectedProfiles.Contains)) + .ToArray(); + if (selectedSecrets.Length == 0) + { + return Array.Empty(); + } + + var targets = await _backend.ListSecretAuthorityTargetsAsync(cancellationToken).ConfigureAwait(false); + var targetsById = targets.Items.ToDictionary(target => target.IntegrationId); + var results = new List(selectedSecrets.Length); + + foreach (var secret in selectedSecrets) + { + if (!ensuredByManifestId.TryGetValue(secret.TargetIntegrationId, out var targetIntegration)) + { + throw new InvalidOperationException( + $"Local integration bootstrap manifest references secret target '{secret.TargetIntegrationId}', but no matching integration was ensured."); + } + + if (!targetsById.TryGetValue(targetIntegration.Id, out var target) || !target.SupportsWrite) + { + throw new InvalidOperationException( + $"Secret target '{targetIntegration.Name}' is not available for staged writes. Ensure the local Vault integration is healthy before bootstrapping secrets."); + } + + var bundleEntries = await GenerateSecretEntriesAsync(secret, cancellationToken).ConfigureAwait(false); + var response = await _backend.UpsertSecretBundleAsync( + secret.BundleId, + new UpsertSecretBundleRequest( + target.IntegrationId, + secret.LogicalPath, + bundleEntries.Select(pair => new SecretBundleEntryRequest(pair.Key, pair.Value)).ToArray(), + secret.Labels, + Overwrite: true), + cancellationToken).ConfigureAwait(false); + + results.Add(new LocalIntegrationBootstrapSecretResult( + secret.Id, + secret.BundleId, + response.LogicalPath, + response.Provider, + response.TargetIntegrationId, + target.Name, + secret.Generator, + response.AuthRefs)); + } + + return results; + } + + private async Task> GenerateSecretEntriesAsync( + LocalIntegrationSecretManifest secret, + CancellationToken cancellationToken) + { + if (!string.Equals(secret.Generator, "gitlab-personal-access-token", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Unsupported local secret generator '{secret.Generator}'."); + } + + var tokenResponse = await CreateGitLabPersonalAccessTokenAsync(secret, cancellationToken).ConfigureAwait(false); + return new Dictionary(StringComparer.Ordinal) + { + ["access-token"] = tokenResponse.Token, + ["registry-basic"] = $"{secret.Username}:{tokenResponse.Token}" + }; + } + + private async Task CreateGitLabPersonalAccessTokenAsync( + LocalIntegrationSecretManifest secret, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(secret.Endpoint) || + string.IsNullOrWhiteSpace(secret.Username) || + string.IsNullOrWhiteSpace(secret.Password) || + string.IsNullOrWhiteSpace(secret.TokenName)) + { + throw new InvalidOperationException("The local GitLab secret generator manifest entry is incomplete."); + } + + using var client = CreateFixtureHttpClient(); + client.BaseAddress = new Uri(secret.Endpoint.TrimEnd('/'), UriKind.Absolute); + client.Timeout = TimeSpan.FromSeconds(60); + + var oauthResponse = await SendAsync( + client, + HttpMethod.Post, + "/oauth/token", + headers: null, + content: new FormUrlEncodedContent(new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "password", + ["username"] = secret.Username, + ["password"] = secret.Password + }), + cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(oauthResponse.AccessToken)) + { + throw new InvalidOperationException("Local GitLab bootstrap did not return an OAuth access token."); + } + + using var adminHeaders = new HttpRequestMessage(); + adminHeaders.Headers.Authorization = new AuthenticationHeaderValue("Bearer", oauthResponse.AccessToken); + + var user = await SendAsync( + client, + HttpMethod.Get, + "/api/v4/user", + adminHeaders.Headers, + cancellationToken).ConfigureAwait(false); + + var existingTokens = await SendAsync>( + client, + HttpMethod.Get, + $"/api/v4/personal_access_tokens?user_id={user.Id}", + adminHeaders.Headers, + cancellationToken).ConfigureAwait(false); + + foreach (var existing in existingTokens.Where(token => + string.Equals(token.Name, secret.TokenName, StringComparison.Ordinal) && + token.Active && + !token.Revoked)) + { + await SendAsync( + client, + HttpMethod.Delete, + $"/api/v4/personal_access_tokens/{existing.Id}", + adminHeaders.Headers, + cancellationToken).ConfigureAwait(false); + } + + var expiresAt = DateTime.UtcNow.AddDays(secret.TokenLifetimeDays <= 0 ? 30 : secret.TokenLifetimeDays) + .ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + var createdToken = await SendAsync( + client, + HttpMethod.Post, + $"/api/v4/users/{user.Id}/personal_access_tokens", + adminHeaders.Headers, + content: JsonContent.Create( + new GitLabCreateTokenRequest( + secret.TokenName, + "Stella Ops local integration bootstrap", + expiresAt, + LocalGitLabPersonalAccessTokenScopes)), + cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(createdToken.Token)) + { + throw new InvalidOperationException("Local GitLab bootstrap did not return the newly created personal access token."); + } + + return createdToken; + } + + private async Task SendAsync( + HttpClient client, + HttpMethod method, + string relativePath, + HttpRequestHeaders? headers, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, relativePath); + CopyHeaders(headers, request.Headers); + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw await CreateFixtureFailureAsync(response, relativePath, cancellationToken).ConfigureAwait(false); + } + } + + private async Task SendAsync( + HttpClient client, + HttpMethod method, + string relativePath, + CancellationToken cancellationToken) + { + return await SendAsync(client, method, relativePath, headers: null, content: null, cancellationToken).ConfigureAwait(false); + } + + private async Task SendAsync( + HttpClient client, + HttpMethod method, + string relativePath, + HttpRequestHeaders? headers, + CancellationToken cancellationToken) + { + return await SendAsync(client, method, relativePath, headers, content: null, cancellationToken).ConfigureAwait(false); + } + + private async Task SendAsync( + HttpClient client, + HttpMethod method, + string relativePath, + HttpRequestHeaders? headers, + HttpContent? content, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, relativePath); + CopyHeaders(headers, request.Headers); + request.Content = content; + + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw await CreateFixtureFailureAsync(response, relativePath, cancellationToken).ConfigureAwait(false); + } + + var payload = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); + return payload ?? throw new InvalidOperationException($"The local fixture bootstrap response for '{relativePath}' was empty."); + } + + private HttpClient CreateFixtureHttpClient() + { + if (_httpClientFactory is not null) + { + return _httpClientFactory.CreateClient(nameof(LocalIntegrationBootstrapper)); + } + + return new HttpClient(); + } + + private static async Task CreateFixtureFailureAsync( + HttpResponseMessage response, + string relativePath, + CancellationToken cancellationToken) + { + var detail = response.Content is null + ? string.Empty + : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + var message = $"Local fixture bootstrap request '{relativePath}' failed with {(int)response.StatusCode} {response.ReasonPhrase}."; + if (!string.IsNullOrWhiteSpace(detail)) + { + message = $"{message} {detail.Trim()}"; + } + + return new InvalidOperationException(message); + } + + private static string? ResolveAuthRef( + LocalIntegrationManifestDefinition definition, + IReadOnlyDictionary? authRefs) + { + if (definition.AuthRef is null) + { + return null; + } + + if (authRefs is null) + { + throw new InvalidOperationException($"Local integration '{definition.Id}' requires staged authref material, but no staged secrets were available."); + } + + var key = new AuthRefSourceKey(definition.AuthRef.SecretId, definition.AuthRef.EntryKey); + if (!authRefs.TryGetValue(key, out var authRef)) + { + throw new InvalidOperationException( + $"Local integration '{definition.Id}' requires authref '{definition.AuthRef.SecretId}#{definition.AuthRef.EntryKey}', but the bootstrap workflow did not generate it."); + } + + return authRef; + } + + private static bool NeedsUpdate(IntegrationResponse existing, LocalIntegrationManifestDefinition definition, string? authRefUri) + { + if (!string.Equals(existing.Name, definition.Name, StringComparison.Ordinal) || + !string.Equals(existing.Description, definition.Description, StringComparison.Ordinal) || + !string.Equals(existing.OrganizationId, definition.OrganizationId, StringComparison.Ordinal) || + !TagsEqual(existing.Tags, definition.Tags)) + { + return true; + } + + return !string.IsNullOrWhiteSpace(authRefUri) && !existing.HasAuth; + } + + private static bool TagsEqual(IReadOnlyList left, IReadOnlyList right) + { + if (left.Count != right.Count) + { + return false; + } + + var leftOrdered = left.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase).ToArray(); + var rightOrdered = right.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase).ToArray(); + return leftOrdered.SequenceEqual(rightOrdered, StringComparer.OrdinalIgnoreCase); + } + + private static IReadOnlyDictionary? ToConfigObjectDictionary(IReadOnlyDictionary? config) + { + if (config is null || config.Count == 0) + { + return null; + } + + return config.ToDictionary( + pair => pair.Key, + pair => JsonElementToObject(pair.Value), + StringComparer.OrdinalIgnoreCase); + } + + private static object JsonElementToObject(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number when element.TryGetInt64(out var intValue) => intValue, + JsonValueKind.Number when element.TryGetDouble(out var doubleValue) => doubleValue, + JsonValueKind.String => element.GetString() ?? string.Empty, + _ => element.ToString() + }; + } + + private static bool IsSecretAuthorityDefinition(LocalIntegrationManifestDefinition definition) + => definition.Type == IntegrationType.SecretsManager && definition.Provider == IntegrationProvider.Vault; + + private static string NormalizeEndpoint(string endpoint) + => endpoint.Trim().TrimEnd('/'); + + private static HashSet BuildSelectedProfiles(LocalIntegrationBootstrapRequest request) + { + var selected = new HashSet(StringComparer.OrdinalIgnoreCase) { "default" }; + if (request.IncludeGitLab) + { + selected.Add("gitlab"); + } + + if (request.IncludeGitLabRegistry) + { + selected.Add("gitlab-registry"); + } + + return selected; + } + + private static void CopyHeaders(HttpRequestHeaders? source, HttpRequestHeaders destination) + { + if (source is null) + { + return; + } + + foreach (var header in source) + { + destination.TryAddWithoutValidation(header.Key, header.Value); + } + } + + private static LocalIntegrationBootstrapManifest LoadManifest() + { + var assembly = typeof(LocalIntegrationBootstrapper).Assembly; + using var stream = assembly.GetManifestResourceStream(ManifestResourceName); + if (stream is null) + { + throw new InvalidOperationException($"Embedded local integration bootstrap manifest '{ManifestResourceName}' was not found."); + } + + return JsonSerializer.Deserialize(stream, JsonOptions) + ?? throw new InvalidOperationException("Embedded local integration bootstrap manifest was empty."); + } + + internal sealed record LocalIntegrationBootstrapRequest( + bool IncludeGitLab, + bool IncludeGitLabRegistry); + + internal sealed record LocalIntegrationBootstrapResult( + string Mode, + IReadOnlyList Profiles, + string ProductionGuidance, + IReadOnlyList Secrets, + IReadOnlyList Integrations, + bool AllHealthy); + + internal sealed record LocalIntegrationBootstrapSecretResult( + string SecretId, + string BundleId, + string LogicalPath, + IntegrationProvider TargetProvider, + Guid TargetIntegrationId, + string TargetName, + string Generator, + IReadOnlyDictionary AuthRefs); + + internal sealed record LocalIntegrationBootstrapIntegrationResult( + string ManifestId, + Guid IntegrationId, + string Name, + IntegrationProvider Provider, + IntegrationType Type, + string Action, + string Endpoint, + bool HasAuth, + bool TestSucceeded, + string? TestMessage, + HealthStatus HealthStatus, + string? HealthMessage); + + private sealed record EnsureIntegrationResult(IntegrationResponse Response, string Action); + + private readonly record struct IntegrationKey(IntegrationProvider Provider, string Endpoint); + + private readonly record struct AuthRefSourceKey(string SecretId, string EntryKey); + + private sealed record LocalIntegrationBootstrapManifest( + string Mode, + string ProductionGuidance, + IReadOnlyList Integrations, + IReadOnlyList Secrets); + + private sealed record LocalIntegrationManifestDefinition( + string Id, + IReadOnlyList Profiles, + string Name, + string? Description, + IntegrationType Type, + IntegrationProvider Provider, + string Endpoint, + string? OrganizationId, + IReadOnlyList Tags, + IReadOnlyDictionary? Config, + LocalIntegrationAuthRefManifest? AuthRef); + + private sealed record LocalIntegrationAuthRefManifest( + string SecretId, + string EntryKey); + + private sealed record LocalIntegrationSecretManifest( + string Id, + IReadOnlyList Profiles, + string BundleId, + string? LogicalPath, + string TargetIntegrationId, + string Generator, + string Endpoint, + string Username, + string Password, + string TokenName, + int TokenLifetimeDays, + IReadOnlyDictionary? Labels); + + private sealed record GitLabPasswordGrantResponse([property: JsonPropertyName("access_token")] string? AccessToken); + + private sealed record GitLabUserResponse([property: JsonPropertyName("id")] int Id); + + private sealed record GitLabExistingTokenResponse( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("active")] bool Active, + [property: JsonPropertyName("revoked")] bool Revoked); + + private sealed record GitLabCreateTokenRequest( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("expires_at")] string ExpiresAt, + [property: JsonPropertyName("scopes")] IReadOnlyList Scopes); + + private sealed record GitLabPersonalAccessTokenResponse( + [property: JsonPropertyName("token")] string Token, + [property: JsonPropertyName("expires_at")] string? ExpiresAt); +} diff --git a/src/Cli/StellaOps.Cli/Commands/Setup/SetupCommandHandler.cs b/src/Cli/StellaOps.Cli/Commands/Setup/SetupCommandHandler.cs index 9ac061043..45ccb74d8 100644 --- a/src/Cli/StellaOps.Cli/Commands/Setup/SetupCommandHandler.cs +++ b/src/Cli/StellaOps.Cli/Commands/Setup/SetupCommandHandler.cs @@ -205,18 +205,27 @@ internal sealed class SetupCommandHandler : ISetupCommandHandler if (specificStep is null && CanFinalize(session)) { - var finalize = await _backend.FinalizeSetupSessionAsync(session.SessionId, false, ct).ConfigureAwait(false); - Console.WriteLine(finalize.Success - ? "Setup finalized successfully." - : $"Setup finalize returned an incomplete state: {finalize.Message}"); - - if (!string.IsNullOrWhiteSpace(finalize.Message)) + try { - Console.WriteLine(finalize.Message); - } + var finalize = await _backend.FinalizeSetupSessionAsync(session.SessionId, false, ct).ConfigureAwait(false); + Console.WriteLine(finalize.Success + ? "Setup finalized successfully." + : $"Setup finalize returned an incomplete state: {finalize.Message}"); - PrintNextSteps(finalize.NextSteps); - return; + if (!string.IsNullOrWhiteSpace(finalize.Message)) + { + Console.WriteLine(finalize.Message); + } + + PrintNextSteps(finalize.NextSteps); + return; + } + catch (Exception ex) + { + Console.WriteLine($"Setup finalization blocked: {ex.Message}"); + Console.WriteLine("Use `stella setup status` to inspect required readiness blockers."); + return; + } } var currentStep = NormalizeStepId(session.CurrentStepId); @@ -274,6 +283,40 @@ internal sealed class SetupCommandHandler : ISetupCommandHandler createdAtUtc = session.CreatedAtUtc, updatedAtUtc = session.UpdatedAtUtc, completedAtUtc = session.CompletedAtUtc, + readiness = session.Readiness is null + ? null + : new + { + status = NormalizeReadinessStatus(session.Readiness.Status), + readyToProceed = session.Readiness.ReadyToProceed, + requiredDependencyCount = session.Readiness.RequiredDependencyCount, + requiredReadyCount = session.Readiness.RequiredReadyCount, + optionalDependencyCount = session.Readiness.OptionalDependencyCount, + optionalReadyCount = session.Readiness.OptionalReadyCount, + blockingDependencyCount = session.Readiness.BlockingDependencyCount, + message = session.Readiness.Message, + checkedAt = session.Readiness.CheckedAt, + dependencies = session.Readiness.Dependencies.Select(dependency => new + { + service = dependency.Service, + displayName = dependency.DisplayName, + category = dependency.Category, + required = dependency.Required, + blocksSetup = dependency.BlocksSetup, + status = NormalizeReadinessStatus(dependency.Status), + endpoint = dependency.Endpoint, + version = dependency.Version, + checkedAt = dependency.CheckedAt, + message = dependency.Message, + latencyMs = dependency.LatencyMs + }) + }, + secretDrafts = session.SecretDrafts.Select(secret => new + { + key = secret.Key, + stepId = NormalizeStepId(secret.StepId), + updatedAtUtc = secret.UpdatedAtUtc + }), steps = session.Steps.Select(step => new { stepId = NormalizeStepId(step.StepId), @@ -310,6 +353,28 @@ internal sealed class SetupCommandHandler : ISetupCommandHandler Console.WriteLine($"Current step: {GetStepDisplayName(currentStep)}"); } + if (verbose && session.SecretDrafts.Count > 0) + { + Console.WriteLine("Retained secret drafts:"); + foreach (var secret in session.SecretDrafts + .OrderBy(candidate => NormalizeStepId(candidate.StepId), StringComparer.Ordinal) + .ThenBy(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase)) + { + var stepId = NormalizeStepId(secret.StepId); + Console.WriteLine(string.IsNullOrWhiteSpace(stepId) + ? $" - {secret.Key} (updated {FormatUtc(secret.UpdatedAtUtc)})" + : $" - {secret.Key} [{GetStepDisplayName(stepId)}] (updated {FormatUtc(secret.UpdatedAtUtc)})"); + } + + Console.WriteLine(); + } + + if (session.Readiness is not null) + { + PrintOperationalReadiness(session.Readiness, verbose); + Console.WriteLine(); + } + Console.WriteLine(); Console.WriteLine("Steps:"); foreach (var stepId in ControlPlaneSteps) @@ -633,6 +698,22 @@ internal sealed class SetupCommandHandler : ISetupCommandHandler }; } + private static string NormalizeReadinessStatus(string? rawStatus) + { + if (string.IsNullOrWhiteSpace(rawStatus)) + { + return "unknown"; + } + + return rawStatus.Trim().ToLowerInvariant() switch + { + "ready" => "ready", + "degraded" => "degraded", + "blocked" => "blocked", + _ => rawStatus.Trim().ToLowerInvariant() + }; + } + private static string GetStepDisplayName(string stepId) => StepDisplayNames.TryGetValue(stepId, out var display) ? display @@ -674,6 +755,60 @@ internal sealed class SetupCommandHandler : ISetupCommandHandler } } + private static void PrintOperationalReadiness(BackendPlatformReadiness readiness, bool verbose) + { + Console.WriteLine("Operational readiness:"); + Console.WriteLine($" {NormalizeReadinessStatus(readiness.Status).ToUpperInvariant()}: {readiness.Message}"); + Console.WriteLine($" Required ready: {readiness.RequiredReadyCount}/{readiness.RequiredDependencyCount}"); + if (readiness.OptionalDependencyCount > 0) + { + Console.WriteLine($" Optional ready: {readiness.OptionalReadyCount}/{readiness.OptionalDependencyCount}"); + } + + var requiredBlockers = readiness.Dependencies + .Where(candidate => candidate.Required) + .Where(candidate => !string.Equals(candidate.Status, "ready", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (requiredBlockers.Length > 0) + { + Console.WriteLine(" Required blockers:"); + foreach (var blocker in requiredBlockers) + { + Console.WriteLine($" - {blocker.DisplayName}: {blocker.Message ?? NormalizeReadinessStatus(blocker.Status)}"); + if (verbose && !string.IsNullOrWhiteSpace(blocker.Endpoint)) + { + Console.WriteLine($" endpoint: {blocker.Endpoint}"); + } + } + } + + if (!verbose) + { + return; + } + + var optionalIssues = readiness.Dependencies + .Where(candidate => !candidate.Required) + .Where(candidate => !string.Equals(candidate.Status, "ready", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (optionalIssues.Length == 0) + { + return; + } + + Console.WriteLine(" Optional issues:"); + foreach (var issue in optionalIssues) + { + Console.WriteLine($" - {issue.DisplayName}: {issue.Message ?? NormalizeReadinessStatus(issue.Status)}"); + if (!string.IsNullOrWhiteSpace(issue.Endpoint)) + { + Console.WriteLine($" endpoint: {issue.Endpoint}"); + } + } + } + private static void PrintWarnings(IEnumerable warnings) { foreach (var warning in warnings) diff --git a/src/Cli/StellaOps.Cli/Commands/SystemCommandBuilder.cs b/src/Cli/StellaOps.Cli/Commands/SystemCommandBuilder.cs index ba89731b6..82184541d 100644 --- a/src/Cli/StellaOps.Cli/Commands/SystemCommandBuilder.cs +++ b/src/Cli/StellaOps.Cli/Commands/SystemCommandBuilder.cs @@ -36,10 +36,11 @@ internal static class SystemCommandBuilder Option verboseOption, CancellationToken cancellationToken) { - var moduleChoices = string.Join(", ", MigrationModuleRegistry.ModuleNames.OrderBy(static n => n)); var moduleOption = new Option("--module") { - Description = $"Module name ({moduleChoices}, all)" + // Avoid eager module discovery during CLI startup; some commands do not need + // the full migration registry loaded just to render the root command tree. + Description = "Module name (use `all` for every module)." }; var categoryOption = new Option("--category") { diff --git a/src/Cli/StellaOps.Cli/Commands/local-integration-bootstrap.manifest.json b/src/Cli/StellaOps.Cli/Commands/local-integration-bootstrap.manifest.json new file mode 100644 index 000000000..3bf2a9195 --- /dev/null +++ b/src/Cli/StellaOps.Cli/Commands/local-integration-bootstrap.manifest.json @@ -0,0 +1,248 @@ +{ + "mode": "local-compose-fixtures", + "productionGuidance": "This command is only for Stella-owned local compose fixtures. For production or customer-managed systems, stage operator-provided secrets with `stella config integrations secrets upsert-bundle` and then create or update integrations with explicit `authref://...` bindings.", + "integrations": [ + { + "id": "local-harbor-fixture", + "profiles": ["default"], + "name": "Local Harbor Fixture", + "description": "Local Harbor mock fixture for registry onboarding and health checks.", + "type": "Registry", + "provider": "Harbor", + "endpoint": "http://harbor-fixture.stella-ops.local", + "organizationId": "local-fixtures", + "tags": ["local", "scratch-setup", "registry"], + "config": { + "scheduleType": "manual" + } + }, + { + "id": "local-docker-registry", + "profiles": ["default"], + "name": "Local Docker Registry", + "description": "Local open OCI registry for catalog and tag probe validation.", + "type": "Registry", + "provider": "DockerHub", + "endpoint": "http://registry.stella-ops.local:5000", + "tags": ["local", "scratch-setup", "registry"], + "config": { + "scheduleType": "manual" + } + }, + { + "id": "local-nexus-registry", + "profiles": ["default"], + "name": "Local Nexus Registry", + "description": "Local Nexus Repository Manager for registry integration checks.", + "type": "Registry", + "provider": "Nexus", + "endpoint": "http://nexus.stella-ops.local:8081", + "tags": ["local", "scratch-setup", "registry"], + "config": { + "scheduleType": "manual" + } + }, + { + "id": "local-github-app-fixture", + "profiles": ["default"], + "name": "Local GitHub App Fixture", + "description": "Deterministic GitHub App fixture for SCM integration checks.", + "type": "Scm", + "provider": "GitHubApp", + "endpoint": "http://github-app-fixture.stella-ops.local", + "organizationId": "local-fixtures", + "tags": ["local", "scratch-setup", "scm"], + "config": { + "scheduleType": "manual" + } + }, + { + "id": "local-gitea-server", + "profiles": ["default"], + "name": "Local Gitea Server", + "description": "Local Gitea service for SCM connectivity and repository discovery.", + "type": "Scm", + "provider": "Gitea", + "endpoint": "http://gitea.stella-ops.local:3000", + "organizationId": "local", + "tags": ["local", "scratch-setup", "scm"], + "config": { + "scheduleType": "manual" + } + }, + { + "id": "local-jenkins", + "profiles": ["default"], + "name": "Local Jenkins", + "description": "Local Jenkins service for CI/CD integration checks.", + "type": "CiCd", + "provider": "Jenkins", + "endpoint": "http://jenkins.stella-ops.local:8080", + "tags": ["local", "scratch-setup", "cicd"], + "config": { + "scheduleType": "manual" + } + }, + { + "id": "local-ebpf-runtime-host", + "profiles": ["default"], + "name": "Local eBPF Runtime Host", + "description": "Local runtime-host fixture exposing the eBPF agent contract.", + "type": "RuntimeHost", + "provider": "EbpfAgent", + "endpoint": "http://runtime-host-fixture.stella-ops.local", + "tags": ["local", "scratch-setup", "runtime-host"], + "config": { + "scheduleType": "manual" + } + }, + { + "id": "local-stellaops-mirror", + "profiles": ["default"], + "name": "Local StellaOps Mirror", + "description": "Local Concelier mirror health surface for the StellaOps mirror provider.", + "type": "FeedMirror", + "provider": "StellaOpsMirror", + "endpoint": "http://concelier.stella-ops.local", + "tags": ["local", "scratch-setup", "feed-mirror"], + "config": { + "scheduleType": "manual" + } + }, + { + "id": "local-nvd-mirror", + "profiles": ["default"], + "name": "Local NVD Mirror", + "description": "Local Concelier mirror health surface for the NVD mirror provider.", + "type": "FeedMirror", + "provider": "NvdMirror", + "endpoint": "http://concelier.stella-ops.local", + "tags": ["local", "scratch-setup", "feed-mirror"], + "config": { + "scheduleType": "manual" + } + }, + { + "id": "local-osv-mirror", + "profiles": ["default"], + "name": "Local OSV Mirror", + "description": "Local Concelier mirror health surface for the OSV mirror provider.", + "type": "FeedMirror", + "provider": "OsvMirror", + "endpoint": "http://concelier.stella-ops.local", + "tags": ["local", "scratch-setup", "feed-mirror"], + "config": { + "scheduleType": "manual" + } + }, + { + "id": "local-vault", + "profiles": ["default"], + "name": "Local Vault", + "description": "Local HashiCorp Vault dev server for secrets integration checks.", + "type": "SecretsManager", + "provider": "Vault", + "endpoint": "http://vault.stella-ops.local:8200", + "tags": ["local", "scratch-setup", "secrets"], + "config": { + "scheduleType": "manual" + } + }, + { + "id": "local-consul", + "profiles": ["default"], + "name": "Local Consul", + "description": "Local Consul server for settings and service-discovery checks.", + "type": "SecretsManager", + "provider": "Consul", + "endpoint": "http://consul.stella-ops.local:8500", + "tags": ["local", "scratch-setup", "secrets"], + "config": { + "scheduleType": "manual" + } + }, + { + "id": "local-minio", + "profiles": ["default"], + "name": "Local MinIO", + "description": "Local MinIO server for S3-compatible storage integration checks.", + "type": "ObjectStorage", + "provider": "S3Compatible", + "endpoint": "http://minio.stella-ops.local:9000", + "tags": ["local", "scratch-setup", "storage"], + "config": { + "scheduleType": "manual" + } + }, + { + "id": "local-gitlab-server", + "profiles": ["gitlab"], + "name": "Local GitLab Server", + "description": "Local GitLab server for SCM connectivity and discovery probes.", + "type": "Scm", + "provider": "GitLabServer", + "endpoint": "http://gitlab.stella-ops.local:8929", + "tags": ["local", "scratch-setup", "scm"], + "config": { + "scheduleType": "manual" + }, + "authRef": { + "secretId": "gitlab", + "entryKey": "access-token" + } + }, + { + "id": "local-gitlab-ci", + "profiles": ["gitlab"], + "name": "Local GitLab CI", + "description": "Local GitLab CI surface for CI/CD connectivity checks.", + "type": "CiCd", + "provider": "GitLabCi", + "endpoint": "http://gitlab.stella-ops.local:8929", + "tags": ["local", "scratch-setup", "cicd"], + "config": { + "scheduleType": "manual" + }, + "authRef": { + "secretId": "gitlab", + "entryKey": "access-token" + } + }, + { + "id": "local-gitlab-container-registry", + "profiles": ["gitlab-registry"], + "name": "Local GitLab Container Registry", + "description": "Local GitLab container registry surface. Requires the heavy GitLab profile with registry enabled.", + "type": "Registry", + "provider": "GitLabContainerRegistry", + "endpoint": "http://gitlab.stella-ops.local:5050", + "tags": ["local", "scratch-setup", "registry"], + "config": { + "scheduleType": "manual" + }, + "authRef": { + "secretId": "gitlab", + "entryKey": "registry-basic" + } + } + ], + "secrets": [ + { + "id": "gitlab", + "profiles": ["gitlab", "gitlab-registry"], + "bundleId": "gitlab-server", + "logicalPath": "gitlab", + "targetIntegrationId": "local-vault", + "generator": "gitlab-personal-access-token", + "endpoint": "http://gitlab.stella-ops.local:8929", + "username": "root", + "password": "Stella2026!", + "tokenName": "stella-local-integration", + "tokenLifetimeDays": 30, + "labels": { + "managed-by": "stellaops-cli", + "scope": "local-compose" + } + } + ] +} diff --git a/src/Cli/StellaOps.Cli/Configuration/CliBootstrapper.cs b/src/Cli/StellaOps.Cli/Configuration/CliBootstrapper.cs index 4bf99dc7b..fa3af48d7 100644 --- a/src/Cli/StellaOps.Cli/Configuration/CliBootstrapper.cs +++ b/src/Cli/StellaOps.Cli/Configuration/CliBootstrapper.cs @@ -143,7 +143,9 @@ public static class CliBootstrapper "Authority:TokenCacheDirectory"); authority.Url = authority.Url?.Trim() ?? string.Empty; - authority.ClientId = authority.ClientId?.Trim() ?? string.Empty; + authority.ClientId = string.IsNullOrWhiteSpace(authority.ClientId) + ? StellaOpsCliAuthorityOptions.DefaultHumanClientId + : authority.ClientId.Trim(); authority.ClientSecret = string.IsNullOrWhiteSpace(authority.ClientSecret) ? null : authority.ClientSecret.Trim(); authority.Username = authority.Username?.Trim() ?? string.Empty; authority.Password = string.IsNullOrWhiteSpace(authority.Password) ? null : authority.Password.Trim(); @@ -367,11 +369,6 @@ public static class CliBootstrapper private static string ResolveWithFallback(string currentValue, IConfiguration configuration, params string[] keys) { - if (!string.IsNullOrWhiteSpace(currentValue)) - { - return currentValue; - } - foreach (var key in keys) { var value = configuration[key]; @@ -381,7 +378,7 @@ public static class CliBootstrapper } } - return string.Empty; + return string.IsNullOrWhiteSpace(currentValue) ? string.Empty : currentValue; } private static bool TryParseBoolean(string value, out bool parsed) diff --git a/src/Cli/StellaOps.Cli/Configuration/StellaOpsCliOptions.cs b/src/Cli/StellaOps.Cli/Configuration/StellaOpsCliOptions.cs index 6c4981509..38eede9ca 100644 --- a/src/Cli/StellaOps.Cli/Configuration/StellaOpsCliOptions.cs +++ b/src/Cli/StellaOps.Cli/Configuration/StellaOpsCliOptions.cs @@ -66,6 +66,9 @@ public sealed class StellaOpsCliOptions public sealed class StellaOpsCliAuthorityOptions { + public const string DefaultHumanClientId = "stellaops-cli"; + public const string DefaultAutomationClientId = "stellaops-cli-automation"; + public string Url { get; set; } = string.Empty; public string ClientId { get; set; } = string.Empty; diff --git a/src/Cli/StellaOps.Cli/Program.cs b/src/Cli/StellaOps.Cli/Program.cs index 7d005dd21..1759615c0 100644 --- a/src/Cli/StellaOps.Cli/Program.cs +++ b/src/Cli/StellaOps.Cli/Program.cs @@ -8,6 +8,7 @@ using StellaOps.Attestor.Oci.Services; using StellaOps.Attestor.StandardPredicates.BinaryDiff; using StellaOps.Auth.Client; using StellaOps.Cli.Commands; +using StellaOps.Cli.Commands.Setup; using StellaOps.Cli.Commands.Scan; using StellaOps.Cli.Configuration; using StellaOps.Cli.Services; @@ -194,6 +195,7 @@ internal static class Program services.AddSingleton(); services.AddSingleton(TimeProvider.System); services.AddSingleton(); + services.AddSetupWizard(); services.AddVexEvidenceLinking(configuration); // CLI-SRC-001: Advisory source registry for sources management commands @@ -426,25 +428,31 @@ internal static class Program await using var serviceProvider = services.BuildServiceProvider(); var loggerFactory = serviceProvider.GetRequiredService(); var startupLogger = loggerFactory.CreateLogger("StellaOps.Cli.Startup"); - AuthorityDiagnosticsReporter.Emit(configuration, startupLogger); - // CLI-CRYPTO-4100-001: Validate crypto configuration on startup - var cryptoValidator = serviceProvider.GetRequiredService(); - var cryptoValidation = cryptoValidator.Validate(serviceProvider); - if (cryptoValidation.HasWarnings) + if (CliStartupDiagnosticsPolicy.ShouldEmit(args)) { - foreach (var warning in cryptoValidation.Warnings) + AuthorityDiagnosticsReporter.Emit(configuration, startupLogger); + + // CLI-CRYPTO-4100-001: Validate crypto configuration on startup + var cryptoValidator = serviceProvider.GetRequiredService(); + var cryptoValidation = cryptoValidator.Validate(serviceProvider); + if (cryptoValidation.HasWarnings) { - startupLogger.LogWarning("Crypto: {Warning}", warning); - } - } - if (cryptoValidation.HasErrors) - { - foreach (var error in cryptoValidation.Errors) - { - startupLogger.LogError("Crypto: {Error}", error); + foreach (var warning in cryptoValidation.Warnings) + { + startupLogger.LogWarning("Crypto: {Warning}", warning); + } + } + + if (cryptoValidation.HasErrors) + { + foreach (var error in cryptoValidation.Errors) + { + startupLogger.LogError("Crypto: {Error}", error); + } } } + using var cts = new CancellationTokenSource(); Console.CancelKeyPress += (_, eventArgs) => { diff --git a/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs b/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs index 42715193b..e8264448c 100644 --- a/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs +++ b/src/Cli/StellaOps.Cli/Services/BackendOperationsClient.cs @@ -2492,7 +2492,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient return; } - request.Headers.TryAddWithoutValidation("X-Tenant-Id", tenantId.Trim()); + request.Headers.TryAddWithoutValidation("X-StellaOps-Tenant", tenantId.Trim()); } private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri) @@ -2511,7 +2511,9 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient requestUri = new Uri(relativeUri.TrimStart('/'), UriKind.Relative); } - return new HttpRequestMessage(method, requestUri); + var request = new HttpRequestMessage(method, requestUri); + ApplyTenantHeader(request, TenantProfileStore.GetEffectiveTenant(_options.DefaultTenant)); + return request; } private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) @@ -5677,7 +5679,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient EnsureBackendConfigured(); using var httpRequest = CreateRequest(HttpMethod.Put, $"api/v1/setup/sessions/{Uri.EscapeDataString(sessionId)}/config"); - httpRequest.Content = JsonContent.Create(new { configValues }, options: SerializerOptions); + httpRequest.Content = CreateConfigValuesContent(configValues); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); @@ -5778,6 +5780,25 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient ?? throw new InvalidOperationException("Finalize setup session response was empty."); } + public async Task GetPlatformReadinessAsync(CancellationToken cancellationToken) + { + EnsureBackendConfigured(); + + using var httpRequest = CreateRequest(HttpMethod.Get, "api/v1/platform/health/readiness"); + await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); + + using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException($"Failed to get platform readiness: {failure}"); + } + + var envelope = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); + return envelope?.Item + ?? throw new InvalidOperationException("Platform readiness response was empty."); + } + public async Task ListSecretAuthorityTargetsAsync(CancellationToken cancellationToken) { EnsureBackendConfigured(); @@ -6112,7 +6133,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient using var httpRequest = CreateRequest( HttpMethod.Post, $"api/v1/setup/sessions/{Uri.EscapeDataString(sessionId)}/steps/{Uri.EscapeDataString(stepId)}/{action}"); - httpRequest.Content = JsonContent.Create(new { configValues }, options: SerializerOptions); + httpRequest.Content = CreateConfigValuesContent(configValues); await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); @@ -6138,7 +6159,10 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient throw new InvalidOperationException(emptyMessage); } - return envelope.Session; + return envelope.Session with + { + Readiness = envelope.Readiness + }; } private sealed class IntegrationDiscoveryErrorResponse @@ -6147,4 +6171,15 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient public List SupportedResourceTypes { get; init; } = []; } + + private static HttpContent CreateConfigValuesContent(IReadOnlyDictionary configValues) + { + var materialized = new Dictionary(configValues, StringComparer.OrdinalIgnoreCase); + return JsonContent.Create(new ConfigValuesEnvelope { ConfigValues = materialized }, options: SerializerOptions); + } + + private sealed class ConfigValuesEnvelope + { + public Dictionary ConfigValues { get; init; } = new(StringComparer.OrdinalIgnoreCase); + } } diff --git a/src/Cli/StellaOps.Cli/Services/CliStartupDiagnosticsPolicy.cs b/src/Cli/StellaOps.Cli/Services/CliStartupDiagnosticsPolicy.cs new file mode 100644 index 000000000..2db58fa33 --- /dev/null +++ b/src/Cli/StellaOps.Cli/Services/CliStartupDiagnosticsPolicy.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; + +namespace StellaOps.Cli.Services; + +internal static class CliStartupDiagnosticsPolicy +{ + private static readonly HashSet StructuredFormats = new(StringComparer.OrdinalIgnoreCase) + { + "cdx", + "cdx-json", + "csv", + "cyclonedx", + "html", + "json", + "markdown", + "md", + "ndjson", + "openvex", + "raw", + "sarif", + "spdx", + "spdx-json", + "spdx3", + "yaml", + "yml" + }; + + public static bool ShouldEmit(string[] args) + { + ArgumentNullException.ThrowIfNull(args); + return IsVerboseRequested(args) && !RequestsStructuredOutput(args); + } + + internal static bool IsVerboseRequested(IReadOnlyList args) + { + ArgumentNullException.ThrowIfNull(args); + + foreach (var arg in args) + { + var token = arg?.Trim(); + if (string.IsNullOrEmpty(token)) + { + continue; + } + + if (string.Equals(token, "--verbose", StringComparison.OrdinalIgnoreCase) || + string.Equals(token, "-v", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + internal static bool RequestsStructuredOutput(IReadOnlyList args) + { + ArgumentNullException.ThrowIfNull(args); + + for (var index = 0; index < args.Count; index++) + { + var token = args[index]?.Trim(); + if (string.IsNullOrEmpty(token)) + { + continue; + } + + if (string.Equals(token, "--json", StringComparison.OrdinalIgnoreCase) || + string.Equals(token, "--raw", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (TryReadOptionValue(args, ref index, token, "--format", "-f", out var formatValue) && + IsStructuredFormat(formatValue)) + { + return true; + } + + if (TryReadOptionValue(args, ref index, token, "--output-format", shortOptionName: null, out var outputFormatValue) && + IsStructuredFormat(outputFormatValue)) + { + return true; + } + } + + return false; + } + + private static bool TryReadOptionValue( + IReadOnlyList args, + ref int index, + string token, + string longOptionName, + string? shortOptionName, + out string? value) + { + value = null; + + if (TryReadInlineValue(token, longOptionName, out value) || + (!string.IsNullOrWhiteSpace(shortOptionName) && TryReadInlineValue(token, shortOptionName, out value))) + { + return true; + } + + if (string.Equals(token, longOptionName, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(shortOptionName) && string.Equals(token, shortOptionName, StringComparison.OrdinalIgnoreCase))) + { + if (index + 1 >= args.Count) + { + return false; + } + + var next = args[index + 1]?.Trim(); + if (string.IsNullOrEmpty(next) || next.StartsWith("-", StringComparison.Ordinal)) + { + return false; + } + + index++; + value = next; + return true; + } + + return false; + } + + private static bool TryReadInlineValue(string token, string optionName, out string? value) + { + value = null; + + if (!token.StartsWith(optionName, StringComparison.OrdinalIgnoreCase) || + token.Length <= optionName.Length || + token[optionName.Length] != '=') + { + return false; + } + + value = token[(optionName.Length + 1)..].Trim(); + return true; + } + + private static bool IsStructuredFormat(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return StructuredFormats.Contains(value.Trim()); + } +} diff --git a/src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs b/src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs index a2135b0b1..68703386b 100644 --- a/src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs +++ b/src/Cli/StellaOps.Cli/Services/IBackendOperationsClient.cs @@ -214,6 +214,9 @@ internal interface IBackendOperationsClient Task FinalizeSetupSessionAsync(string sessionId, bool force, CancellationToken cancellationToken) => Task.FromException(new NotSupportedException("Setup session APIs are not implemented by this backend client.")); + Task GetPlatformReadinessAsync(CancellationToken cancellationToken) => + Task.FromException(new NotSupportedException("Platform readiness APIs are not implemented by this backend client.")); + Task ListSecretAuthorityTargetsAsync(CancellationToken cancellationToken) => Task.FromException(new NotSupportedException("Secret authority APIs are not implemented by this backend client.")); diff --git a/src/Cli/StellaOps.Cli/Services/Models/SetupModels.cs b/src/Cli/StellaOps.Cli/Services/Models/SetupModels.cs index ad9bc93eb..9f7e39503 100644 --- a/src/Cli/StellaOps.Cli/Services/Models/SetupModels.cs +++ b/src/Cli/StellaOps.Cli/Services/Models/SetupModels.cs @@ -6,6 +6,7 @@ namespace StellaOps.Cli.Services.Models; internal sealed record SetupSessionEnvelope { public BackendSetupSession? Session { get; init; } + public BackendPlatformReadiness? Readiness { get; init; } } internal sealed record BackendSetupSession @@ -16,10 +17,58 @@ internal sealed record BackendSetupSession public string? CurrentStepId { get; init; } public string DefinitionVersion { get; init; } = string.Empty; public Dictionary DraftValues { get; init; } = new(StringComparer.OrdinalIgnoreCase); + public List SecretDrafts { get; init; } = []; public List Steps { get; init; } = []; public string CreatedAtUtc { get; init; } = string.Empty; public string UpdatedAtUtc { get; init; } = string.Empty; public string? CompletedAtUtc { get; init; } + public BackendPlatformReadiness? Readiness { get; init; } +} + +internal sealed record BackendPlatformReadiness +{ + public string Status { get; init; } = string.Empty; + public bool ReadyToProceed { get; init; } + public int RequiredDependencyCount { get; init; } + public int RequiredReadyCount { get; init; } + public int OptionalDependencyCount { get; init; } + public int OptionalReadyCount { get; init; } + public int BlockingDependencyCount { get; init; } + public string Message { get; init; } = string.Empty; + public DateTimeOffset CheckedAt { get; init; } + public List Dependencies { get; init; } = []; +} + +internal sealed record BackendPlatformDependency +{ + public string Service { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public string Category { get; init; } = string.Empty; + public bool Required { get; init; } + public bool BlocksSetup { get; init; } + public string Status { get; init; } = string.Empty; + public string? Endpoint { get; init; } + public string Version { get; init; } = string.Empty; + public DateTimeOffset CheckedAt { get; init; } + public string? Message { get; init; } + public double? LatencyMs { get; init; } +} + +internal sealed record PlatformReadinessEnvelope +{ + public string TenantId { get; init; } = string.Empty; + public string? ActorId { get; init; } + public DateTimeOffset DataAsOf { get; init; } + public bool Cached { get; init; } + public int CacheTtlSeconds { get; init; } + public BackendPlatformReadiness? Item { get; init; } +} + +internal sealed record BackendSetupSecretDraft +{ + public string Key { get; init; } = string.Empty; + public string? StepId { get; init; } + public string UpdatedAtUtc { get; init; } = string.Empty; } internal sealed record BackendSetupStepState diff --git a/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj b/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj index edca4b0c1..b9a25a301 100644 --- a/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj +++ b/src/Cli/StellaOps.Cli/StellaOps.Cli.csproj @@ -49,6 +49,9 @@ StellaOps.Cli.cli-routes.json + + StellaOps.Cli.Commands.local-integration-bootstrap.manifest.json + diff --git a/src/Cli/StellaOps.Cli/TASKS.md b/src/Cli/StellaOps.Cli/TASKS.md index 4430a307e..a3819b1c8 100644 --- a/src/Cli/StellaOps.Cli/TASKS.md +++ b/src/Cli/StellaOps.Cli/TASKS.md @@ -73,3 +73,6 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | PAPI-005 | DONE | SPRINT_20260210_005 - DevPortal portable-v1 verify parity and deterministic error-code output completed; CLI verifier paths validated in suite run (1173 passed) on 2026-02-10. | | SPRINT_20260224_004-LOC-303 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added `stella tenants locale get` and `stella tenants locale set ` command surface with tenant-scoped backend calls to Platform language preferences API. | | SPRINT_20260224_004-LOC-308-CLI | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added `stella tenants locale list` (Platform locale catalog endpoint) and catalog-aware pre-validation in `tenants locale set` for deterministic locale selection behavior. | +| PLATFORM-BOOT-003 | DONE | Sprint `docs/implplan/SPRINT_20260416_002_Platform_bootstrap_auth_and_integration_onboarding_hardening.md`: aligned CLI auth docs/help with the seeded human login path and gated startup diagnostics so structured output commands stay clean. | +| PLATFORM-BOOT-004 | DONE | Sprint `docs/implplan/SPRINT_20260416_002_Platform_bootstrap_auth_and_integration_onboarding_hardening.md`: add a manifest-driven `config integrations bootstrap local` workflow for Stella-owned compose fixtures, including first-party GitLab secret staging and catalog health verification. | +| PLATFORM-BOOT-005 | DONE | Sprint `docs/implplan/SPRINT_20260416_002_Platform_bootstrap_auth_and_integration_onboarding_hardening.md`: `setup status` and `admin diagnostics health` now consume the backend readiness contract instead of static output or implied compose knowledge. | diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs index e4e1e8bc2..a729e3a0e 100644 --- a/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs +++ b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/CommandHandlersTests.cs @@ -1696,6 +1696,95 @@ public sealed class CommandHandlersTests } } + [Fact] + public async Task HandleAuthLoginAsync_UsesPromptedPasswordFlowForDefaultHumanClient() + { + var originalExitCode = Environment.ExitCode; + var originalConsole = AnsiConsole.Console; + using var tempDir = new TempDirectory(); + + try + { + var console = new TestConsole(); + console.Interactive(); + console.Input.PushTextWithEnter("admin"); + console.Input.PushTextWithEnter("Admin@Stella2026!"); + AnsiConsole.Console = console; + + var options = new StellaOpsCliOptions + { + ResultsDirectory = Path.Combine(tempDir.Path, "results"), + Authority = new StellaOpsCliAuthorityOptions + { + Url = "https://authority.example", + ClientId = StellaOpsCliAuthorityOptions.DefaultHumanClientId, + Scope = StellaOpsScopes.ConcelierJobsTrigger, + TokenCacheDirectory = tempDir.Path + } + }; + + var tokenClient = new StubTokenClient(); + var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient); + + await CommandHandlers.HandleAuthLoginAsync(provider, options, verbose: false, force: false, cancellationToken: CancellationToken.None); + + Assert.Equal(0, Environment.ExitCode); + Assert.Equal(1, tokenClient.PasswordRequests); + Assert.Equal(0, tokenClient.ClientCredentialRequests); + Assert.Equal("admin", tokenClient.LastUsername); + Assert.Equal("Admin@Stella2026!", tokenClient.LastPassword); + Assert.NotNull(tokenClient.CachedEntry); + Assert.NotNull(tokenClient.CachedEntry!.Metadata); + Assert.Equal("password", tokenClient.CachedEntry.Metadata!["grant_type"]); + Assert.Equal("admin", tokenClient.CachedEntry.Metadata["username"]); + } + finally + { + Environment.ExitCode = originalExitCode; + AnsiConsole.Console = originalConsole; + } + } + + [Fact] + public async Task HandleAuthLoginAsync_DefaultHumanClientFailsWithoutInteractiveConsole() + { + var originalExitCode = Environment.ExitCode; + var originalConsole = AnsiConsole.Console; + using var tempDir = new TempDirectory(); + + try + { + AnsiConsole.Console = new TestConsole(); + + var options = new StellaOpsCliOptions + { + ResultsDirectory = Path.Combine(tempDir.Path, "results"), + Authority = new StellaOpsCliAuthorityOptions + { + Url = "https://authority.example", + ClientId = StellaOpsCliAuthorityOptions.DefaultHumanClientId, + Scope = StellaOpsScopes.ConcelierJobsTrigger, + TokenCacheDirectory = tempDir.Path + } + }; + + var tokenClient = new StubTokenClient(); + var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient); + + await CommandHandlers.HandleAuthLoginAsync(provider, options, verbose: false, force: false, cancellationToken: CancellationToken.None); + + Assert.Equal(1, Environment.ExitCode); + Assert.Equal(0, tokenClient.PasswordRequests); + Assert.Equal(0, tokenClient.ClientCredentialRequests); + Assert.Null(tokenClient.CachedEntry); + } + finally + { + Environment.ExitCode = originalExitCode; + AnsiConsole.Console = originalConsole; + } + } + [Fact] public async Task HandleAuthLoginAsync_FailsWhenPasswordMissing() { @@ -2215,6 +2304,60 @@ public sealed class CommandHandlersTests } } + [Fact] + public async Task HandleAuthStatusAsync_ReportsCachedGrantTypeMetadata() + { + var original = Environment.ExitCode; + using var tempDir = new TempDirectory(); + + try + { + var options = new StellaOpsCliOptions + { + ResultsDirectory = Path.Combine(tempDir.Path, "results"), + Authority = new StellaOpsCliAuthorityOptions + { + Url = "https://authority.example", + ClientId = StellaOpsCliAuthorityOptions.DefaultHumanClientId, + TokenCacheDirectory = tempDir.Path + } + }; + + var tokenClient = new StubTokenClient + { + CachedEntry = new StellaOpsTokenCacheEntry( + "opaque-token", + "Bearer", + DateTimeOffset.UtcNow.AddMinutes(30), + new[] { StellaOpsScopes.ConcelierJobsTrigger }, + Metadata: new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "password", + ["username"] = "admin" + }) + }; + + var loggerProvider = new TestLoggerProvider(); + var provider = BuildServiceProvider( + new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), + options: options, + tokenClient: tokenClient, + loggerProvider: loggerProvider); + + await CommandHandlers.HandleAuthStatusAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None); + + Assert.Equal(0, Environment.ExitCode); + Assert.Contains( + loggerProvider.Entries, + entry => entry.Category == "auth-status" && + entry.Message.Contains("Grant type: password", StringComparison.OrdinalIgnoreCase)); + } + finally + { + Environment.ExitCode = original; + } + } + [Fact] public async Task HandleAuthWhoAmIAsync_ReturnsErrorWhenTokenMissing() { @@ -2246,6 +2389,64 @@ public sealed class CommandHandlersTests } } + [Fact] + public async Task HandleAuthWhoAmIAsync_ReportsCachedUsernameMetadataForOpaqueToken() + { + var original = Environment.ExitCode; + using var tempDir = new TempDirectory(); + + try + { + var options = new StellaOpsCliOptions + { + ResultsDirectory = Path.Combine(tempDir.Path, "results"), + Authority = new StellaOpsCliAuthorityOptions + { + Url = "https://authority.example", + ClientId = StellaOpsCliAuthorityOptions.DefaultHumanClientId, + TokenCacheDirectory = tempDir.Path + } + }; + + var tokenClient = new StubTokenClient + { + CachedEntry = new StellaOpsTokenCacheEntry( + "opaque-token", + "Bearer", + DateTimeOffset.UtcNow.AddMinutes(30), + new[] { StellaOpsScopes.ConcelierJobsTrigger }, + Metadata: new Dictionary(StringComparer.Ordinal) + { + ["grant_type"] = "password", + ["username"] = "admin" + }) + }; + + var loggerProvider = new TestLoggerProvider(); + var provider = BuildServiceProvider( + new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), + options: options, + tokenClient: tokenClient, + loggerProvider: loggerProvider); + + await CommandHandlers.HandleAuthWhoAmIAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None); + + Assert.Equal(0, Environment.ExitCode); + Assert.Contains( + loggerProvider.Entries, + entry => entry.Category == "auth-whoami" && + entry.Message.Contains("Grant type: password", StringComparison.OrdinalIgnoreCase)); + Assert.Contains( + loggerProvider.Entries, + entry => entry.Category == "auth-whoami" && + entry.Message.Contains("Username: admin", StringComparison.OrdinalIgnoreCase)); + } + finally + { + Environment.ExitCode = original; + } + } + [Fact] public async Task HandleAuthWhoAmIAsync_ReportsClaimsForJwtToken() { @@ -4876,6 +5077,8 @@ public sealed class CommandHandlersTests public IReadOnlyDictionary? LastAdditionalParameters { get; private set; } public int PasswordRequests { get; private set; } public int ClearRequests { get; private set; } + public string? LastUsername { get; private set; } + public string? LastPassword { get; private set; } public StellaOpsTokenCacheEntry? CachedEntry { get; set; } public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default) @@ -4907,6 +5110,8 @@ public sealed class CommandHandlersTests public Task RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary? additionalParameters = null, CancellationToken cancellationToken = default) { PasswordRequests++; + LastUsername = username; + LastPassword = password; LastAdditionalParameters = additionalParameters; return Task.FromResult(_token); } diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/IntegrationsCommandGroupTests.cs b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/IntegrationsCommandGroupTests.cs index 390f04e18..ff6760134 100644 --- a/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/IntegrationsCommandGroupTests.cs +++ b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/IntegrationsCommandGroupTests.cs @@ -1,11 +1,15 @@ using System.CommandLine; using System.Globalization; using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Moq; using StellaOps.Cli.Commands; using StellaOps.Cli.Services; +using StellaOps.Cli.Tests.Testing; using StellaOps.Integrations.Contracts; using StellaOps.Integrations.Core; using StellaOps.TestKit; @@ -219,6 +223,234 @@ public sealed class IntegrationsCommandGroupTests backend.VerifyAll(); } + [Fact] + public async Task BootstrapLocalCommand_DefaultProfile_CreatesAndVerifiesFixtureCatalog() + { + var createdRequests = new List(); + var createdIntegrations = new Dictionary(); + var nextId = 1; + var fixedTime = DateTimeOffset.Parse("2026-04-16T11:00:00Z", CultureInfo.InvariantCulture); + + var backend = new Mock(MockBehavior.Strict); + backend + .Setup(client => client.ListIntegrationsAsync( + null, + null, + null, + null, + 1, + 200, + "name", + false, + It.IsAny())) + .ReturnsAsync(new PagedIntegrationsResponse([], 0, 1, 200, 1)); + backend + .Setup(client => client.CreateIntegrationAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((CreateIntegrationRequest request, CancellationToken _) => + { + createdRequests.Add(request); + var id = Guid.Parse($"00000000-0000-0000-0000-{nextId.ToString("D12", CultureInfo.InvariantCulture)}"); + nextId++; + var response = CreateIntegrationResponse(request, id, fixedTime); + createdIntegrations[id] = response; + return response; + }); + backend + .Setup(client => client.TestIntegrationAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Guid id, CancellationToken _) => new TestConnectionResponse( + id, + Success: true, + "ok", + null, + TimeSpan.FromMilliseconds(250), + fixedTime)); + backend + .Setup(client => client.GetIntegrationHealthAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Guid id, CancellationToken _) => new HealthCheckResponse( + id, + HealthStatus.Healthy, + "healthy", + null, + fixedTime, + TimeSpan.FromMilliseconds(150))); + + using var services = new ServiceCollection() + .AddSingleton(backend.Object) + .BuildServiceProvider(); + + var root = new RootCommand(); + root.Add(IntegrationsCommandGroup.BuildIntegrationsCommand(services, new Option("--verbose"), CancellationToken.None)); + + var invocation = await InvokeWithCapturedConsoleAsync(root, "integrations bootstrap local --format json"); + + Assert.Equal(0, invocation.ExitCode); + using var document = JsonDocument.Parse(invocation.StdOut); + Assert.Equal("local-compose-fixtures", document.RootElement.GetProperty("mode").GetString()); + Assert.True(document.RootElement.GetProperty("allHealthy").GetBoolean()); + Assert.Equal(1, document.RootElement.GetProperty("profiles").GetArrayLength()); + Assert.Equal("default", document.RootElement.GetProperty("profiles")[0].GetString()); + Assert.Equal(0, document.RootElement.GetProperty("secrets").GetArrayLength()); + Assert.Equal(13, document.RootElement.GetProperty("integrations").GetArrayLength()); + Assert.Contains(createdRequests, request => request.Provider == IntegrationProvider.Vault && request.Endpoint == "http://vault.stella-ops.local:8200"); + Assert.DoesNotContain(createdRequests, request => !string.IsNullOrWhiteSpace(request.AuthRefUri)); + + backend.VerifyAll(); + } + + [Fact] + public async Task BootstrapLocalCommand_GitLabProfiles_StagesSecretsAndBindsAuthRefs() + { + var createdRequests = new List(); + var createdIntegrations = new Dictionary(); + var stagedBundles = new List<(string BundleId, UpsertSecretBundleRequest Request)>(); + var nextId = 1; + var fixedTime = DateTimeOffset.Parse("2026-04-16T12:00:00Z", CultureInfo.InvariantCulture); + + var backend = new Mock(MockBehavior.Strict); + backend + .Setup(client => client.ListIntegrationsAsync( + null, + null, + null, + null, + 1, + 200, + "name", + false, + It.IsAny())) + .ReturnsAsync(new PagedIntegrationsResponse([], 0, 1, 200, 1)); + backend + .Setup(client => client.CreateIntegrationAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((CreateIntegrationRequest request, CancellationToken _) => + { + createdRequests.Add(request); + var id = Guid.Parse($"00000000-0000-0000-0000-{nextId.ToString("D12", CultureInfo.InvariantCulture)}"); + nextId++; + var response = CreateIntegrationResponse(request, id, fixedTime); + createdIntegrations[id] = response; + return response; + }); + backend + .Setup(client => client.ListSecretAuthorityTargetsAsync(It.IsAny())) + .ReturnsAsync(() => + { + var vault = createdIntegrations.Values.Single(integration => integration.Provider == IntegrationProvider.Vault); + return new SecretAuthorityTargetsResponse( + [ + new SecretAuthorityTargetResponse( + vault.Id, + vault.Name, + vault.Provider, + vault.Endpoint, + "healthy", + "local-vault", + SupportsWrite: true) + ]); + }); + backend + .Setup(client => client.UpsertSecretBundleAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string bundleId, UpsertSecretBundleRequest request, CancellationToken _) => + { + stagedBundles.Add((bundleId, request)); + return new UpsertSecretBundleResponse( + request.TargetIntegrationId, + bundleId, + request.LogicalPath ?? bundleId, + new Dictionary(StringComparer.Ordinal) + { + ["access-token"] = "authref://vault/gitlab#access-token", + ["registry-basic"] = "authref://vault/gitlab#registry-basic" + }, + IntegrationProvider.Vault, + "http://vault.stella-ops.local:8200"); + }); + backend + .Setup(client => client.TestIntegrationAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Guid id, CancellationToken _) => new TestConnectionResponse( + id, + Success: true, + "ok", + null, + TimeSpan.FromMilliseconds(250), + fixedTime)); + backend + .Setup(client => client.GetIntegrationHealthAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Guid id, CancellationToken _) => new HealthCheckResponse( + id, + HealthStatus.Healthy, + "healthy", + null, + fixedTime, + TimeSpan.FromMilliseconds(150))); + + var fixtureHandler = new StubHttpMessageHandler( + (request, _) => + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("/oauth/token", request.RequestUri!.AbsolutePath); + return JsonResponse("""{"access_token":"oauth-token"}"""); + }, + (request, _) => + { + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal("/api/v4/user", request.RequestUri!.AbsolutePath); + Assert.Equal("Bearer", request.Headers.Authorization?.Scheme); + Assert.Equal("oauth-token", request.Headers.Authorization?.Parameter); + return JsonResponse("""{"id":42}"""); + }, + (request, _) => + { + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal("/api/v4/personal_access_tokens", request.RequestUri!.AbsolutePath); + Assert.Equal("user_id=42", request.RequestUri!.Query.TrimStart('?')); + return JsonResponse("[]"); + }, + (request, _) => + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("/api/v4/users/42/personal_access_tokens", request.RequestUri!.AbsolutePath); + var body = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + Assert.Contains("\"name\":\"stella-local-integration\"", body, StringComparison.Ordinal); + Assert.Contains("\"api\"", body, StringComparison.Ordinal); + Assert.DoesNotContain("\"read_registry\"", body, StringComparison.Ordinal); + return JsonResponse("""{"token":"glpat-local-generated","expires_at":"2026-05-16"}"""); + }); + + var httpClientFactory = new Mock(MockBehavior.Strict); + httpClientFactory + .Setup(factory => factory.CreateClient("LocalIntegrationBootstrapper")) + .Returns(new HttpClient(fixtureHandler, disposeHandler: true)); + + using var services = new ServiceCollection() + .AddSingleton(backend.Object) + .AddSingleton(httpClientFactory.Object) + .BuildServiceProvider(); + + var root = new RootCommand(); + root.Add(IntegrationsCommandGroup.BuildIntegrationsCommand(services, new Option("--verbose"), CancellationToken.None)); + + var invocation = await InvokeWithCapturedConsoleAsync( + root, + "integrations bootstrap local --include-gitlab --include-gitlab-registry --format json"); + + Assert.Equal(0, invocation.ExitCode); + using var document = JsonDocument.Parse(invocation.StdOut); + Assert.Equal(3, document.RootElement.GetProperty("profiles").GetArrayLength()); + Assert.Equal(1, document.RootElement.GetProperty("secrets").GetArrayLength()); + Assert.Equal(16, document.RootElement.GetProperty("integrations").GetArrayLength()); + Assert.Single(stagedBundles); + Assert.Equal("gitlab-server", stagedBundles[0].BundleId); + Assert.Equal("gitlab", stagedBundles[0].Request.LogicalPath); + Assert.Contains(stagedBundles[0].Request.Entries, entry => entry.Key == "access-token" && entry.Value == "glpat-local-generated"); + Assert.Contains(stagedBundles[0].Request.Entries, entry => entry.Key == "registry-basic" && entry.Value == "root:glpat-local-generated"); + Assert.Contains(createdRequests, request => request.Provider == IntegrationProvider.GitLabServer && request.AuthRefUri == "authref://vault/gitlab#access-token"); + Assert.Contains(createdRequests, request => request.Provider == IntegrationProvider.GitLabCi && request.AuthRefUri == "authref://vault/gitlab#access-token"); + Assert.Contains(createdRequests, request => request.Provider == IntegrationProvider.GitLabContainerRegistry && request.AuthRefUri == "authref://vault/gitlab#registry-basic"); + + backend.VerifyAll(); + httpClientFactory.VerifyAll(); + } + private static async Task InvokeWithCapturedConsoleAsync(RootCommand root, string commandLine) { var originalOut = Console.Out; @@ -245,5 +477,37 @@ public sealed class IntegrationsCommandGroupTests } } + private static IntegrationResponse CreateIntegrationResponse( + CreateIntegrationRequest request, + Guid id, + DateTimeOffset timestamp) + { + return new IntegrationResponse( + id, + request.Name, + request.Description, + request.Type, + request.Provider, + IntegrationStatus.Pending, + request.Endpoint, + !string.IsNullOrWhiteSpace(request.AuthRefUri), + request.OrganizationId, + HealthStatus.Unknown, + null, + timestamp, + timestamp, + "cli-test", + "cli-test", + request.Tags ?? []); + } + + private static HttpResponseMessage JsonResponse(string json) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + private sealed record CommandInvocationResult(int ExitCode, string StdOut, string StdErr); } diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/SetupAndAdminReadinessTests.cs b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/SetupAndAdminReadinessTests.cs new file mode 100644 index 000000000..1dd2886a8 --- /dev/null +++ b/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/SetupAndAdminReadinessTests.cs @@ -0,0 +1,187 @@ +using System; +using System.CommandLine; +using System.Globalization; +using System.IO; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using StellaOps.Cli.Commands.Admin; +using StellaOps.Cli.Commands.Setup; +using StellaOps.Cli.Commands.Setup.Config; +using StellaOps.Cli.Services; +using StellaOps.Cli.Services.Models; +using StellaOps.Doctor.Detection; +using StellaOps.TestKit; +using Xunit; + +namespace StellaOps.Cli.Tests.Commands; + +[Trait("Category", TestCategories.Unit)] +public sealed class SetupAndAdminReadinessTests +{ + [Fact] + public async Task StatusAsync_PrintsRequiredReadinessBlockers() + { + var backend = new Mock(MockBehavior.Strict); + backend + .Setup(client => client.GetCurrentSetupSessionAsync(It.IsAny())) + .ReturnsAsync(new BackendSetupSession + { + SessionId = "setup-001", + ScopeKey = "installation", + Status = "InProgress", + CurrentStepId = "crypto", + DefinitionVersion = "2026-04-control-plane-v1", + CreatedAtUtc = "2026-04-16T08:00:00Z", + UpdatedAtUtc = "2026-04-16T08:05:00Z", + Readiness = new BackendPlatformReadiness + { + Status = "blocked", + ReadyToProceed = false, + RequiredDependencyCount = 7, + RequiredReadyCount = 6, + OptionalDependencyCount = 0, + OptionalReadyCount = 0, + BlockingDependencyCount = 1, + Message = "Required dependencies blocking setup: Front Door.", + CheckedAt = DateTimeOffset.Parse("2026-04-16T08:05:00Z", CultureInfo.InvariantCulture), + Dependencies = + [ + new BackendPlatformDependency + { + Service = "frontdoor", + DisplayName = "Front Door", + Category = "core-services", + Required = true, + BlocksSetup = true, + Status = "blocked", + Endpoint = "http://router.stella-ops.local/health/ready", + Version = "unknown", + CheckedAt = DateTimeOffset.Parse("2026-04-16T08:05:00Z", CultureInfo.InvariantCulture), + Message = "Front-door readiness probe returned 503." + } + ] + }, + Steps = + [ + new BackendSetupStepState { StepId = "database", Status = "passed" }, + new BackendSetupStepState { StepId = "cache", Status = "passed" }, + new BackendSetupStepState { StepId = "migrations", Status = "passed" }, + new BackendSetupStepState { StepId = "admin", Status = "passed" }, + new BackendSetupStepState { StepId = "crypto", Status = "pending" } + ] + }); + + var parser = new Mock(MockBehavior.Strict); + var runtimeDetector = new Mock(MockBehavior.Strict); + using var loggerFactory = LoggerFactory.Create(_ => { }); + var handler = new SetupCommandHandler( + backend.Object, + parser.Object, + runtimeDetector.Object, + loggerFactory.CreateLogger()); + + var originalOut = Console.Out; + var writer = new StringWriter(CultureInfo.InvariantCulture); + + try + { + Console.SetOut(writer); + await handler.StatusAsync(sessionId: null, json: false, verbose: true, ct: CancellationToken.None); + } + finally + { + Console.SetOut(originalOut); + } + + var output = writer.ToString(); + Assert.Contains("Operational readiness:", output, StringComparison.OrdinalIgnoreCase); + Assert.Contains("BLOCKED", output, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Front Door", output, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Required blockers:", output, StringComparison.OrdinalIgnoreCase); + backend.VerifyAll(); + } + + [Fact] + public async Task AdminDiagnosticsHealth_JsonOutput_UsesPlatformReadiness() + { + var backend = new Mock(MockBehavior.Strict); + backend + .Setup(client => client.GetPlatformReadinessAsync(It.IsAny())) + .ReturnsAsync(new BackendPlatformReadiness + { + Status = "degraded", + ReadyToProceed = true, + RequiredDependencyCount = 7, + RequiredReadyCount = 7, + OptionalDependencyCount = 1, + OptionalReadyCount = 0, + BlockingDependencyCount = 0, + Message = "Required dependencies are ready. Optional services degraded: Release Orchestrator.", + CheckedAt = DateTimeOffset.Parse("2026-04-16T09:00:00Z", CultureInfo.InvariantCulture), + Dependencies = + [ + new BackendPlatformDependency + { + Service = "release-orchestrator", + DisplayName = "Release Orchestrator", + Category = "post-boot", + Required = false, + BlocksSetup = false, + Status = "degraded", + Endpoint = "http://release-orchestrator.stella-ops.local", + Version = "unknown", + CheckedAt = DateTimeOffset.Parse("2026-04-16T09:00:00Z", CultureInfo.InvariantCulture), + Message = "Reachability probe returned 503." + } + ] + }); + + using var services = new ServiceCollection() + .AddSingleton(backend.Object) + .BuildServiceProvider(); + + var root = new RootCommand(); + root.Add(AdminCommandGroup.BuildAdminCommand(services, new Option("--verbose"), CancellationToken.None)); + + var invocation = await InvokeWithCapturedConsoleAsync(root, "admin diagnostics health --format json"); + + Assert.Equal(0, invocation.ExitCode); + using var document = JsonDocument.Parse(invocation.StdOut); + Assert.Equal("degraded", document.RootElement.GetProperty("status").GetString()); + Assert.True(document.RootElement.GetProperty("readyToProceed").GetBoolean()); + Assert.Equal(1, document.RootElement.GetProperty("dependencies").GetArrayLength()); + Assert.Equal("release-orchestrator", document.RootElement.GetProperty("dependencies")[0].GetProperty("service").GetString()); + + backend.VerifyAll(); + } + + private static async Task InvokeWithCapturedConsoleAsync(RootCommand root, string commandLine) + { + var originalOut = Console.Out; + var originalError = Console.Error; + var originalExitCode = Environment.ExitCode; + Environment.ExitCode = 0; + + var stdout = new StringWriter(CultureInfo.InvariantCulture); + var stderr = new StringWriter(CultureInfo.InvariantCulture); + + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + var exitCode = await root.Parse(commandLine).InvokeAsync(); + var capturedExitCode = Environment.ExitCode != 0 ? Environment.ExitCode : exitCode; + return new CommandInvocationResult(capturedExitCode, stdout.ToString(), stderr.ToString()); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + Environment.ExitCode = originalExitCode; + } + } + + private sealed record CommandInvocationResult(int ExitCode, string StdOut, string StdErr); +} diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/Configuration/CliBootstrapperTests.cs b/src/Cli/__Tests/StellaOps.Cli.Tests/Configuration/CliBootstrapperTests.cs index d7ea86aa8..58814ebc5 100644 --- a/src/Cli/__Tests/StellaOps.Cli.Tests/Configuration/CliBootstrapperTests.cs +++ b/src/Cli/__Tests/StellaOps.Cli.Tests/Configuration/CliBootstrapperTests.cs @@ -24,7 +24,7 @@ public sealed class CliBootstrapperTests : IDisposable Environment.SetEnvironmentVariable("STELLAOPS_BACKEND_URL", "https://env-backend.example"); Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_URL", "https://authority.env"); Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_CLIENT_ID", "cli-env"); - Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SCOPE", "concelier.jobs.trigger"); + Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SCOPE", "integration:read integration:write integration:operate"); Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ENABLE_RETRIES", "false"); Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_RETRY_DELAYS", "00:00:02,00:00:05"); Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK", "false"); @@ -38,7 +38,7 @@ public sealed class CliBootstrapperTests : IDisposable Assert.Equal("https://env-backend.example", options.BackendUrl); Assert.Equal("https://authority.env", options.Authority.Url); Assert.Equal("cli-env", options.Authority.ClientId); - Assert.Equal("concelier.jobs.trigger", options.Authority.Scope); + Assert.Equal("integration:read integration:write integration:operate", options.Authority.Scope); Assert.NotNull(options.Authority.Resilience); Assert.False(options.Authority.Resilience.EnableRetries); @@ -86,6 +86,22 @@ public sealed class CliBootstrapperTests : IDisposable Assert.Equal("cli-file", options.Authority.ClientId); } + [Fact] + public void Build_UsesDefaultHumanAuthorityClientIdWhenUnset() + { + var (options, _) = CliBootstrapper.Build(Array.Empty()); + + Assert.Equal(StellaOpsCliAuthorityOptions.DefaultHumanClientId, options.Authority.ClientId); + } + + [Fact] + public void Build_FallsBackToDefaultScopeWhenNoOverrideIsPresent() + { + var (options, _) = CliBootstrapper.Build(Array.Empty()); + + Assert.Equal("concelier.jobs.trigger", options.Authority.Scope); + } + public void Dispose() { Directory.SetCurrentDirectory(_originalDirectory); diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/Services/BackendOperationsClientTests.cs b/src/Cli/__Tests/StellaOps.Cli.Tests/Services/BackendOperationsClientTests.cs index c2afe72d8..2d9722646 100644 --- a/src/Cli/__Tests/StellaOps.Cli.Tests/Services/BackendOperationsClientTests.cs +++ b/src/Cli/__Tests/StellaOps.Cli.Tests/Services/BackendOperationsClientTests.cs @@ -20,6 +20,8 @@ using StellaOps.Cli.Services; using StellaOps.Cli.Services.Models; using StellaOps.Cli.Services.Models.Transport; using StellaOps.Cli.Tests.Testing; +using StellaOps.Integrations.Contracts; +using StellaOps.Integrations.Core; using StellaOps.Scanner.EntryTrace; using StellaOps.Cryptography; using System.Linq; @@ -262,6 +264,203 @@ public sealed class BackendOperationsClientTests Assert.Equal(2, attempts); } + [Fact] + public async Task SaveSetupSessionConfigAsync_SendsConfigValuesEnvelope() + { + string? requestBody = null; + + var handler = new StubHttpMessageHandler((request, _) => + { + requestBody = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + + var payload = JsonSerializer.Serialize(new + { + data = new + { + saved = true, + session = new + { + sessionId = "setup-1", + scopeKey = "installation", + status = "InProgress", + currentStepId = "admin", + definitionVersion = "2026-04-control-plane-v1", + draftValues = new Dictionary + { + ["users.superuser.username"] = "admin" + }, + secretDrafts = new[] + { + new + { + key = "users.superuser.password", + stepId = "admin", + updatedAtUtc = "2026-04-16T07:00:00Z" + } + }, + steps = Array.Empty(), + createdAtUtc = "2026-04-16T07:00:00Z", + updatedAtUtc = "2026-04-16T07:00:00Z" + } + } + }, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + }); + + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://platform.example") + }; + + var options = new StellaOpsCliOptions + { + BackendUrl = "https://platform.example" + }; + + var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); + var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger(), CryptoHash); + + var configValues = new ReadOnlyDictionary(new Dictionary + { + ["users.superuser.username"] = "admin", + ["users.superuser.email"] = "admin@stella-ops.local" + }); + + var session = await client.SaveSetupSessionConfigAsync("setup-1", configValues, CancellationToken.None); + + using var document = JsonDocument.Parse(requestBody!); + var sentConfig = document.RootElement.GetProperty("configValues"); + Assert.Equal("admin", sentConfig.GetProperty("users.superuser.username").GetString()); + Assert.Equal("admin@stella-ops.local", sentConfig.GetProperty("users.superuser.email").GetString()); + Assert.Equal("setup-1", session.SessionId); + var retainedSecret = Assert.Single(session.SecretDrafts); + Assert.Equal("users.superuser.password", retainedSecret.Key); + Assert.Equal("admin", retainedSecret.StepId); + } + + [Fact] + public async Task ApplySetupStepAsync_SendsConfigValuesEnvelope() + { + string? requestBody = null; + + var handler = new StubHttpMessageHandler((request, _) => + { + requestBody = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + + var payload = JsonSerializer.Serialize(new + { + data = new + { + stepId = "admin", + status = "completed", + message = "Bootstrap administrator ensured successfully.", + appliedConfig = new Dictionary(), + outputValues = new Dictionary(), + canRetry = false, + validationResults = Array.Empty() + } + }, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + }); + + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://platform.example") + }; + + var options = new StellaOpsCliOptions + { + BackendUrl = "https://platform.example" + }; + + var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); + var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger(), CryptoHash); + + var configValues = new ReadOnlyDictionary(new Dictionary + { + ["users.superuser.username"] = "admin", + ["users.superuser.email"] = "admin@stella-ops.local", + ["users.superuser.password"] = "Admin@Stella2026!" + }); + + var result = await client.ApplySetupStepAsync("setup-1", "admin", configValues, CancellationToken.None); + + using var document = JsonDocument.Parse(requestBody!); + var sentConfig = document.RootElement.GetProperty("configValues"); + Assert.Equal("admin", sentConfig.GetProperty("users.superuser.username").GetString()); + Assert.Equal("admin@stella-ops.local", sentConfig.GetProperty("users.superuser.email").GetString()); + Assert.Equal("Admin@Stella2026!", sentConfig.GetProperty("users.superuser.password").GetString()); + Assert.Equal("completed", result.Status); + } + + [Fact] + public async Task ListIntegrationProvidersAsync_SendsTenantHeaderFromProfile() + { + var originalTenant = Environment.GetEnvironmentVariable("STELLAOPS_TENANT"); + Environment.SetEnvironmentVariable("STELLAOPS_TENANT", "demo-prod"); + + try + { + HttpRequestMessage? captured = null; + + var handler = new StubHttpMessageHandler((request, _) => + { + captured = request; + + var payload = JsonSerializer.Serialize(new[] + { + new ProviderInfo( + "Docker Registry", + IntegrationType.Registry, + IntegrationProvider.DockerHub, + false, + false, + Array.Empty()) + }, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + }); + + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://integrations.example") + }; + + var options = new StellaOpsCliOptions + { + BackendUrl = "https://integrations.example" + }; + + var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug)); + var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger(), CryptoHash); + + var providers = await client.ListIntegrationProvidersAsync(includeTestOnly: false, CancellationToken.None); + + Assert.Single(providers); + Assert.NotNull(captured); + Assert.True(captured!.Headers.TryGetValues("X-StellaOps-Tenant", out var values)); + Assert.Contains("demo-prod", values); + } + finally + { + Environment.SetEnvironmentVariable("STELLAOPS_TENANT", originalTenant); + } + } + [Fact] public async Task GetEntryTraceAsync_ReturnsResponse() { diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/Services/CliStartupDiagnosticsPolicyTests.cs b/src/Cli/__Tests/StellaOps.Cli.Tests/Services/CliStartupDiagnosticsPolicyTests.cs new file mode 100644 index 000000000..9c4c613d2 --- /dev/null +++ b/src/Cli/__Tests/StellaOps.Cli.Tests/Services/CliStartupDiagnosticsPolicyTests.cs @@ -0,0 +1,21 @@ +using FluentAssertions; +using StellaOps.Cli.Services; +using Xunit; + +namespace StellaOps.Cli.Tests.Services; + +public sealed class CliStartupDiagnosticsPolicyTests +{ + [Theory] + [InlineData(true, "--verbose", "auth", "login")] + [InlineData(false, "auth", "login")] + [InlineData(false, "--verbose", "scan", "diff", "--json")] + [InlineData(false, "--verbose", "graph", "verify", "--format", "json")] + [InlineData(false, "--verbose", "unknowns", "list", "-f=json")] + [InlineData(false, "--verbose", "auth", "token", "mint", "--raw")] + [InlineData(true, "--verbose", "tenants", "list", "--format", "table")] + public void ShouldEmit_FollowsVerboseAndStructuredOutputPolicy(bool expected, params string[] args) + { + CliStartupDiagnosticsPolicy.ShouldEmit(args).Should().Be(expected); + } +} diff --git a/src/Cli/__Tests/StellaOps.Cli.Tests/TASKS.md b/src/Cli/__Tests/StellaOps.Cli.Tests/TASKS.md index a08575235..d74b6a9fb 100644 --- a/src/Cli/__Tests/StellaOps.Cli.Tests/TASKS.md +++ b/src/Cli/__Tests/StellaOps.Cli.Tests/TASKS.md @@ -53,3 +53,6 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229 | PAPI-005-TESTS | DONE | SPRINT_20260210_005 - DevPortal portable-v1 verifier matrix hardened with manifest/DSSE/Rekor/Parquet fail-closed tests; CLI suite passed (1182 passed) on 2026-02-10. | | SPRINT_20260224_004-LOC-303-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: updated `CommandHandlersTests` backend stubs for new locale preference client methods; full-suite execution reached `1196/1201` with unrelated pre-existing failures in migration/knowledge-search/risk-budget test lanes. | | SPRINT_20260224_004-LOC-308-CLI-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added command-handler coverage for locale catalog listing (`tenants locale list`) and unsupported locale rejection before preference writes. | +| PLATFORM-BOOT-003-T | DONE | Sprint `docs/implplan/SPRINT_20260416_002_Platform_bootstrap_auth_and_integration_onboarding_hardening.md`: added regression coverage for startup diagnostic gating, fresh-shell auth behavior, token cache reuse metadata, and tenant-scoped authenticated commands. | +| PLATFORM-BOOT-004-T | DONE | Sprint `docs/implplan/SPRINT_20260416_002_Platform_bootstrap_auth_and_integration_onboarding_hardening.md`: add targeted command coverage for manifest-driven local fixture bootstrap, GitLab secret staging, and resulting integration health/test summaries. | +| PLATFORM-BOOT-005-T | DONE | Sprint `docs/implplan/SPRINT_20260416_002_Platform_bootstrap_auth_and_integration_onboarding_hardening.md`: added CLI coverage for readiness-aware setup status and backend-driven admin diagnostics health output. | diff --git a/src/Platform/StellaOps.Platform.WebService/Contracts/HealthModels.cs b/src/Platform/StellaOps.Platform.WebService/Contracts/HealthModels.cs index 183534475..3bc894eac 100644 --- a/src/Platform/StellaOps.Platform.WebService/Contracts/HealthModels.cs +++ b/src/Platform/StellaOps.Platform.WebService/Contracts/HealthModels.cs @@ -8,6 +8,18 @@ public sealed record PlatformHealthSummary( int IncidentCount, IReadOnlyList Services); +public sealed record PlatformReadinessSummary( + string Status, + bool ReadyToProceed, + int RequiredDependencyCount, + int RequiredReadyCount, + int OptionalDependencyCount, + int OptionalReadyCount, + int BlockingDependencyCount, + string Message, + DateTimeOffset CheckedAt, + IReadOnlyList Dependencies); + public sealed record PlatformHealthServiceStatus( string Service, string Status, @@ -17,10 +29,16 @@ public sealed record PlatformHealthServiceStatus( public sealed record PlatformDependencyStatus( string Service, + string DisplayName, + string Category, + bool Required, + bool BlocksSetup, string Status, + string? Endpoint, string Version, DateTimeOffset CheckedAt, - string? Message); + string? Message, + double? LatencyMs); public sealed record PlatformIncident( string IncidentId, diff --git a/src/Platform/StellaOps.Platform.WebService/Contracts/SetupWizardModels.cs b/src/Platform/StellaOps.Platform.WebService/Contracts/SetupWizardModels.cs index 6b1b20930..6b64753b9 100644 --- a/src/Platform/StellaOps.Platform.WebService/Contracts/SetupWizardModels.cs +++ b/src/Platform/StellaOps.Platform.WebService/Contracts/SetupWizardModels.cs @@ -242,7 +242,18 @@ public sealed record SetupSession( string? CompletedAtUtc, string? CreatedBy, string? UpdatedBy, - string? DataAsOfUtc); + string? DataAsOfUtc) +{ + public ImmutableArray SecretDrafts { get; init; } = ImmutableArray.Empty; +} + +/// +/// Metadata describing a retained sensitive draft value without exposing its plaintext. +/// +public sealed record SetupSecretDraftState( + string Key, + SetupStepId? StepId, + string UpdatedAtUtc); /// /// State of a single setup step within a session. @@ -332,7 +343,8 @@ public sealed record ResetSetupStepRequest( /// Response for setup session operations. /// public sealed record SetupSessionResponse( - SetupSession Session); + SetupSession Session, + PlatformReadinessSummary? Readiness = null); /// /// Response for step execution. diff --git a/src/Platform/StellaOps.Platform.WebService/Endpoints/PlatformEndpoints.cs b/src/Platform/StellaOps.Platform.WebService/Endpoints/PlatformEndpoints.cs index 2c5552830..107e0d687 100644 --- a/src/Platform/StellaOps.Platform.WebService/Endpoints/PlatformEndpoints.cs +++ b/src/Platform/StellaOps.Platform.WebService/Endpoints/PlatformEndpoints.cs @@ -39,6 +39,27 @@ public static class PlatformEndpoints { var health = platform.MapGroup("/health").WithTags("Platform Health"); + health.MapGet("/readiness", async Task ( + HttpContext context, + PlatformRequestContextResolver resolver, + PlatformHealthService service, + CancellationToken cancellationToken) => + { + if (!TryResolveContext(context, resolver, out var requestContext, out var failure)) + { + return failure!; + } + + var result = await service.GetReadinessAsync(requestContext!, includeOptional: true, cancellationToken).ConfigureAwait(false); + return Results.Ok(new PlatformItemResponse( + requestContext!.TenantId, + requestContext.ActorId, + result.DataAsOf, + result.Cached, + result.CacheTtlSeconds, + result.Value)); + }).RequireAuthorization(PlatformPolicies.HealthRead); + health.MapGet("/summary", async Task ( HttpContext context, PlatformRequestContextResolver resolver, diff --git a/src/Platform/StellaOps.Platform.WebService/Endpoints/SetupEndpoints.cs b/src/Platform/StellaOps.Platform.WebService/Endpoints/SetupEndpoints.cs index 20b376122..a8c61d47b 100644 --- a/src/Platform/StellaOps.Platform.WebService/Endpoints/SetupEndpoints.cs +++ b/src/Platform/StellaOps.Platform.WebService/Endpoints/SetupEndpoints.cs @@ -733,7 +733,7 @@ public static class SetupEndpoints private static async Task ReadJsonBodyAsync(HttpRequest request, CancellationToken ct) { - if (request.ContentLength is null or 0) + if (request.ContentLength == 0) { return default; } diff --git a/src/Platform/StellaOps.Platform.WebService/Options/PlatformServiceOptions.cs b/src/Platform/StellaOps.Platform.WebService/Options/PlatformServiceOptions.cs index 6a2aad379..05f6ba18a 100644 --- a/src/Platform/StellaOps.Platform.WebService/Options/PlatformServiceOptions.cs +++ b/src/Platform/StellaOps.Platform.WebService/Options/PlatformServiceOptions.cs @@ -48,6 +48,7 @@ public sealed class PlatformAuthorityOptions public sealed class PlatformCacheOptions { + public int HealthReadinessSeconds { get; set; } = 15; public int HealthSummarySeconds { get; set; } = 15; public int HealthDependenciesSeconds { get; set; } = 60; public int HealthIncidentsSeconds { get; set; } = 30; @@ -62,6 +63,7 @@ public sealed class PlatformCacheOptions public void Validate() { + RequireNonNegative(HealthReadinessSeconds, nameof(HealthReadinessSeconds)); RequireNonNegative(HealthSummarySeconds, nameof(HealthSummarySeconds)); RequireNonNegative(HealthDependenciesSeconds, nameof(HealthDependenciesSeconds)); RequireNonNegative(HealthIncidentsSeconds, nameof(HealthIncidentsSeconds)); diff --git a/src/Platform/StellaOps.Platform.WebService/Program.cs b/src/Platform/StellaOps.Platform.WebService/Program.cs index 42cb82ceb..2eaedb8d4 100644 --- a/src/Platform/StellaOps.Platform.WebService/Program.cs +++ b/src/Platform/StellaOps.Platform.WebService/Program.cs @@ -42,6 +42,14 @@ builder.Configuration.AddStellaOpsDefaults(options => var bootstrapOptions = builder.Configuration.BindOptions( PlatformServiceOptions.SectionName, static (options, _) => options.Validate()); +var platformUsesPostgres = !string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString); +var testingEnvironment = builder.Environment.IsEnvironment("Testing"); + +if (!platformUsesPostgres && !testingEnvironment) +{ + throw new InvalidOperationException( + "Platform requires Platform:Storage:PostgresConnectionString outside the Testing environment."); +} builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(PlatformServiceOptions.SectionName)) @@ -189,12 +197,19 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -var authorityBootstrapKey = - builder.Configuration["STELLAOPS_BOOTSTRAP_KEY"] - ?? builder.Configuration["Authority:Bootstrap:ApiKey"] - ?? builder.Configuration["Authority:BootstrapKey"] - ?? builder.Configuration["STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__APIKEY"] - ?? string.Empty; +static string FirstNonEmpty(params string?[] values) => + values.FirstOrDefault(static value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; + +var authorityBootstrapKey = FirstNonEmpty( + builder.Configuration["STELLAOPS_BOOTSTRAP_KEY"], + builder.Configuration["Authority:Bootstrap:ApiKey"], + builder.Configuration["Authority:BootstrapKey"], + builder.Configuration["STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__APIKEY"]); + +var setupSecretProtectionKey = FirstNonEmpty( + builder.Configuration["Platform:Setup:SecretProtectionKey"], + builder.Configuration["STELLAOPS_SECRETS_ENCRYPTION_KEY"], + authorityBootstrapKey); builder.Services.AddHttpClient("AuthorityInternal", client => { @@ -255,12 +270,19 @@ builder.Services.AddHttpClient("CryptoProviderProbe", client => new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); }); +builder.Services.AddHttpClient("PlatformDependencyProbe", client => +{ + client.Timeout = TimeSpan.FromSeconds(5); + client.DefaultRequestHeaders.Accept.Add( + new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); +}); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); -var releaseEnvironmentUsesPostgres = !string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString); +var releaseEnvironmentUsesPostgres = platformUsesPostgres; if (releaseEnvironmentUsesPostgres) { builder.Services.AddReleaseOrchestratorEnvironmentPostgresData( @@ -331,7 +353,7 @@ builder.Services.AddSingleton(); // Score history persistence store -if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString)) +if (platformUsesPostgres) { builder.Services.AddSingleton(sp => { @@ -348,6 +370,8 @@ if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(_ => new PlatformSetupSecretProtector(setupSecretProtectionKey)); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -370,9 +394,11 @@ if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString } else { + // Testing-only fallback for the in-process endpoint harness. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -416,7 +442,7 @@ builder.Services.AddSingleton(); // Script registry services (multi-language script editor) -if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString)) +if (platformUsesPostgres) { builder.Services.AddOptions() .Configure(options => @@ -450,10 +476,13 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); } -if (!releaseEnvironmentUsesPostgres && +if (testingEnvironment && + !releaseEnvironmentUsesPostgres && !string.Equals(bootstrapOptions.Storage.Driver, "memory", StringComparison.OrdinalIgnoreCase)) { - app.Logger.LogWarning("Platform storage driver {Driver} is not implemented; using in-memory stores.", bootstrapOptions.Storage.Driver); + app.Logger.LogWarning( + "Platform storage driver {Driver} is not implemented in Testing; using in-memory stores.", + bootstrapOptions.Storage.Driver); } app.UseStellaOpsCors(); diff --git a/src/Platform/StellaOps.Platform.WebService/Services/PlatformHealthService.cs b/src/Platform/StellaOps.Platform.WebService/Services/PlatformHealthService.cs index 1b3ff013e..b10b3ba72 100644 --- a/src/Platform/StellaOps.Platform.WebService/Services/PlatformHealthService.cs +++ b/src/Platform/StellaOps.Platform.WebService/Services/PlatformHealthService.cs @@ -1,11 +1,13 @@ - +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Platform.WebService.Contracts; using StellaOps.Platform.WebService.Options; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -13,20 +15,30 @@ namespace StellaOps.Platform.WebService.Services; public sealed class PlatformHealthService { - private static readonly string[] ServiceNames = - { - "authority", - "orchestrator", - "policy", - "scanner", - "signals", - "notifier" - }; + private static readonly OptionalServiceDefinition[] OptionalServices = + [ + new("release-orchestrator", "Release Orchestrator", "post-boot", "STELLAOPS_RELEASE_ORCHESTRATOR_URL"), + new("policy-engine", "Policy Engine", "post-boot", "STELLAOPS_POLICY_ENGINE_URL"), + new("scanner", "Scanner", "post-boot", "STELLAOPS_SCANNER_URL"), + new("signals", "Signals", "post-boot", "STELLAOPS_SIGNALS_URL"), + new("notify", "Notify", "post-boot", "STELLAOPS_NOTIFY_URL"), + new("scheduler", "Scheduler", "post-boot", "STELLAOPS_SCHEDULER_URL"), + new("registry-token", "Registry Token Service", "post-boot", "STELLAOPS_REGISTRY_TOKENSERVICE_URL"), + new("sbomservice", "SBOM Service", "post-boot", "STELLAOPS_SBOMSERVICE_URL"), + new("packsregistry", "Packs Registry", "post-boot", "STELLAOPS_PACKSREGISTRY_URL"), + new("advisoryai", "Advisory AI", "post-boot", "STELLAOPS_ADVISORYAI_URL") + ]; private readonly PlatformCache cache; private readonly PlatformAggregationMetrics metrics; private readonly TimeProvider timeProvider; + private readonly PlatformServiceOptions platformOptions; private readonly PlatformCacheOptions cacheOptions; + private readonly IConfiguration configuration; + private readonly IPlatformSetupStore setupStore; + private readonly IEnvironmentSettingsStore envSettingsStore; + private readonly SetupStateDetector setupStateDetector; + private readonly IHttpClientFactory httpClientFactory; private readonly ILogger logger; public PlatformHealthService( @@ -34,61 +46,85 @@ public sealed class PlatformHealthService PlatformAggregationMetrics metrics, TimeProvider timeProvider, IOptions options, + IConfiguration configuration, + IPlatformSetupStore setupStore, + IEnvironmentSettingsStore envSettingsStore, + SetupStateDetector setupStateDetector, + IHttpClientFactory httpClientFactory, ILogger logger) { this.cache = cache ?? throw new ArgumentNullException(nameof(cache)); this.metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); - this.cacheOptions = options?.Value.Cache ?? throw new ArgumentNullException(nameof(options)); + this.platformOptions = options?.Value ?? throw new ArgumentNullException(nameof(options)); + this.cacheOptions = platformOptions.Cache; + this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + this.setupStore = setupStore ?? throw new ArgumentNullException(nameof(setupStore)); + this.envSettingsStore = envSettingsStore ?? throw new ArgumentNullException(nameof(envSettingsStore)); + this.setupStateDetector = setupStateDetector ?? throw new ArgumentNullException(nameof(setupStateDetector)); + this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public Task> GetSummaryAsync( + public async Task> GetSummaryAsync( PlatformRequestContext context, CancellationToken cancellationToken) { - return GetCachedAsync( - operation: "health.summary", - cacheKey: $"platform:health:summary:{context.TenantId}", - ttlSeconds: cacheOptions.HealthSummarySeconds, - factory: ct => Task.FromResult(BuildSummary()), - cancellationToken: cancellationToken); + var readiness = await GetReadinessAsync(context, includeOptional: true, cancellationToken).ConfigureAwait(false); + return new PlatformCacheResult( + BuildSummary(readiness.Value), + readiness.DataAsOf, + readiness.Cached, + readiness.CacheTtlSeconds); } - public Task>> GetDependenciesAsync( + public async Task> GetReadinessAsync( PlatformRequestContext context, + bool includeOptional, CancellationToken cancellationToken) { - return GetCachedAsync( - operation: "health.dependencies", - cacheKey: $"platform:health:dependencies:{context.TenantId}", - ttlSeconds: cacheOptions.HealthDependenciesSeconds, - factory: ct => Task.FromResult(BuildDependencies()), - cancellationToken: cancellationToken); + return await GetCachedAsync( + operation: includeOptional ? "health.readiness" : "health.readiness.required", + cacheKey: $"platform:health:readiness:{context.TenantId}:{(includeOptional ? "full" : "required")}", + ttlSeconds: cacheOptions.HealthReadinessSeconds, + factory: ct => BuildReadinessAsync(includeOptional, ct), + cancellationToken: cancellationToken).ConfigureAwait(false); } - public Task>> GetIncidentsAsync( + public async Task>> GetDependenciesAsync( PlatformRequestContext context, CancellationToken cancellationToken) { - return GetCachedAsync( - operation: "health.incidents", - cacheKey: $"platform:health:incidents:{context.TenantId}", - ttlSeconds: cacheOptions.HealthIncidentsSeconds, - factory: ct => Task.FromResult(BuildIncidents()), - cancellationToken: cancellationToken); + var readiness = await GetReadinessAsync(context, includeOptional: true, cancellationToken).ConfigureAwait(false); + return new PlatformCacheResult>( + readiness.Value.Dependencies, + readiness.DataAsOf, + readiness.Cached, + readiness.CacheTtlSeconds); } - public Task>> GetMetricsAsync( + public async Task>> GetIncidentsAsync( PlatformRequestContext context, CancellationToken cancellationToken) { - return GetCachedAsync( - operation: "health.metrics", - cacheKey: $"platform:health:metrics:{context.TenantId}", - ttlSeconds: cacheOptions.HealthMetricsSeconds, - factory: ct => Task.FromResult(BuildMetrics()), - cancellationToken: cancellationToken); + var readiness = await GetReadinessAsync(context, includeOptional: true, cancellationToken).ConfigureAwait(false); + return new PlatformCacheResult>( + BuildIncidents(readiness.Value), + readiness.DataAsOf, + readiness.Cached, + readiness.CacheTtlSeconds); + } + + public async Task>> GetMetricsAsync( + PlatformRequestContext context, + CancellationToken cancellationToken) + { + var readiness = await GetReadinessAsync(context, includeOptional: true, cancellationToken).ConfigureAwait(false); + return new PlatformCacheResult>( + BuildMetrics(readiness.Value), + readiness.DataAsOf, + readiness.Cached, + readiness.CacheTtlSeconds); } private async Task> GetCachedAsync( @@ -125,67 +161,446 @@ public sealed class PlatformHealthService } } - private PlatformHealthSummary BuildSummary() + private async Task BuildReadinessAsync(bool includeOptional, CancellationToken cancellationToken) { var now = timeProvider.GetUtcNow(); - var services = ServiceNames - .Select((service, index) => new PlatformHealthServiceStatus( - service, - Status: "healthy", - Detail: null, - CheckedAt: now, - LatencyMs: 10 + (index * 2))) + var envSettings = await envSettingsStore.GetAllAsync(cancellationToken).ConfigureAwait(false); + var session = await setupStore.GetAsync(PlatformSetupService.InstallationScopeKey, cancellationToken).ConfigureAwait(false); + var setupState = setupStateDetector.Detect(platformOptions.Storage, envSettings); + + var dependencies = new List + { + BuildStepDependency("database", "Database", SetupStepId.Database, session, setupState, now), + BuildStepDependency("cache", "Cache", SetupStepId.Valkey, session, setupState, now), + BuildStepDependency("migrations", "Migrations", SetupStepId.Migrations, session, setupState, now), + BuildStepDependency("admin-bootstrap", "Admin Bootstrap", SetupStepId.Admin, session, setupState, now), + BuildStepDependency("crypto-profile", "Crypto Profile", SetupStepId.Crypto, session, setupState, now) + }; + + var requiredProbeTasks = new[] + { + ProbeFrontdoorAsync(now, cancellationToken), + ProbeAuthorityAsync(now, cancellationToken) + }; + + dependencies.AddRange(await Task.WhenAll(requiredProbeTasks).ConfigureAwait(false)); + + if (includeOptional) + { + var configuredOptionals = OptionalServices + .Select(definition => definition with { Endpoint = NormalizeBaseUrl(configuration[definition.UrlConfigKey]) }) + .Where(definition => !string.IsNullOrWhiteSpace(definition.Endpoint)) + .ToArray(); + + if (configuredOptionals.Length > 0) + { + var optionalProbes = await Task.WhenAll(configuredOptionals + .Select(definition => ProbeReachabilityAsync(definition, now, cancellationToken))) + .ConfigureAwait(false); + dependencies.AddRange(optionalProbes); + } + } + + return BuildReadinessSummary(now, dependencies); + } + + private PlatformDependencyStatus BuildStepDependency( + string service, + string displayName, + SetupStepId stepId, + SetupSession? session, + string? setupState, + DateTimeOffset checkedAt) + { + const string version = "setup-session"; + + if (string.Equals(setupState, "complete", StringComparison.OrdinalIgnoreCase)) + { + return new PlatformDependencyStatus( + Service: service, + DisplayName: displayName, + Category: "bootstrap", + Required: true, + BlocksSetup: true, + Status: "ready", + Endpoint: null, + Version: version, + CheckedAt: checkedAt, + Message: "Installation bootstrap is marked complete.", + LatencyMs: null); + } + + var stepState = session?.Steps.FirstOrDefault(candidate => candidate.StepId == stepId); + if (stepState?.Status == SetupStepStatus.Passed) + { + return new PlatformDependencyStatus( + Service: service, + DisplayName: displayName, + Category: "bootstrap", + Required: true, + BlocksSetup: true, + Status: "ready", + Endpoint: null, + Version: version, + CheckedAt: checkedAt, + Message: $"{displayName} step completed successfully.", + LatencyMs: null); + } + + var detail = stepState?.ErrorMessage; + if (string.IsNullOrWhiteSpace(detail)) + { + detail = stepState is null + ? $"{displayName} step has not been run yet." + : $"{displayName} step is {NormalizeSetupStepStatus(stepState.Status)}."; + } + + return new PlatformDependencyStatus( + Service: service, + DisplayName: displayName, + Category: "bootstrap", + Required: true, + BlocksSetup: true, + Status: "blocked", + Endpoint: null, + Version: version, + CheckedAt: checkedAt, + Message: detail, + LatencyMs: null); + } + + private Task ProbeFrontdoorAsync(DateTimeOffset checkedAt, CancellationToken cancellationToken) + { + var baseUrl = NormalizeBaseUrl(configuration["STELLAOPS_GATEWAY_URL"]) + ?? NormalizeBaseUrl(configuration["STELLAOPS_ROUTER_URL"]) + ?? "http://router.stella-ops.local"; + var endpoint = CombineRelative(baseUrl, "/health/ready"); + + return ProbeHttpAsync( + service: "frontdoor", + displayName: "Front Door", + category: "core-services", + required: true, + blocksSetup: true, + endpoint: endpoint, + checkedAt: checkedAt, + successPredicate: response => (int)response.StatusCode == 200, + successMessageFactory: response => $"Front-door readiness probe returned {(int)response.StatusCode}.", + failureMessageFactory: response => $"Front-door readiness probe returned {(int)response.StatusCode}.", + cancellationToken: cancellationToken); + } + + private async Task ProbeAuthorityAsync(DateTimeOffset checkedAt, CancellationToken cancellationToken) + { + var endpoint = ResolveAuthorityHealthEndpoint(); + var client = httpClientFactory.CreateClient("AuthorityInternal"); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, "health"); + var stopwatch = Stopwatch.StartNew(); + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + + if (response.IsSuccessStatusCode) + { + return new PlatformDependencyStatus( + Service: "authority", + DisplayName: "Authority", + Category: "core-services", + Required: true, + BlocksSetup: true, + Status: "ready", + Endpoint: endpoint, + Version: ResolveResponseVersion(response), + CheckedAt: checkedAt, + Message: $"Authority health probe returned {(int)response.StatusCode}.", + LatencyMs: stopwatch.Elapsed.TotalMilliseconds); + } + + return new PlatformDependencyStatus( + Service: "authority", + DisplayName: "Authority", + Category: "core-services", + Required: true, + BlocksSetup: true, + Status: "blocked", + Endpoint: endpoint, + Version: ResolveResponseVersion(response), + CheckedAt: checkedAt, + Message: $"Authority health probe returned {(int)response.StatusCode}.", + LatencyMs: stopwatch.Elapsed.TotalMilliseconds); + } + catch (Exception ex) + { + return new PlatformDependencyStatus( + Service: "authority", + DisplayName: "Authority", + Category: "core-services", + Required: true, + BlocksSetup: true, + Status: "blocked", + Endpoint: endpoint, + Version: "unknown", + CheckedAt: checkedAt, + Message: ex.Message, + LatencyMs: null); + } + } + + private Task ProbeReachabilityAsync( + OptionalServiceDefinition definition, + DateTimeOffset checkedAt, + CancellationToken cancellationToken) + { + return ProbeHttpAsync( + service: definition.Service, + displayName: definition.DisplayName, + category: definition.Category, + required: false, + blocksSetup: false, + endpoint: definition.Endpoint!, + checkedAt: checkedAt, + successPredicate: response => (int)response.StatusCode < 500, + successMessageFactory: response => $"Reachability probe returned {(int)response.StatusCode}.", + failureMessageFactory: response => $"Reachability probe returned {(int)response.StatusCode}.", + cancellationToken: cancellationToken); + } + + private async Task ProbeHttpAsync( + string service, + string displayName, + string category, + bool required, + bool blocksSetup, + string endpoint, + DateTimeOffset checkedAt, + Func successPredicate, + Func successMessageFactory, + Func failureMessageFactory, + CancellationToken cancellationToken) + { + var client = httpClientFactory.CreateClient("PlatformDependencyProbe"); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, endpoint); + var stopwatch = Stopwatch.StartNew(); + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + + var ready = successPredicate(response); + return new PlatformDependencyStatus( + Service: service, + DisplayName: displayName, + Category: category, + Required: required, + BlocksSetup: blocksSetup, + Status: ready ? "ready" : required ? "blocked" : "degraded", + Endpoint: endpoint, + Version: ResolveResponseVersion(response), + CheckedAt: checkedAt, + Message: ready ? successMessageFactory(response) : failureMessageFactory(response), + LatencyMs: stopwatch.Elapsed.TotalMilliseconds); + } + catch (Exception ex) + { + return new PlatformDependencyStatus( + Service: service, + DisplayName: displayName, + Category: category, + Required: required, + BlocksSetup: blocksSetup, + Status: required ? "blocked" : "degraded", + Endpoint: endpoint, + Version: "unknown", + CheckedAt: checkedAt, + Message: ex.Message, + LatencyMs: null); + } + } + + private static PlatformReadinessSummary BuildReadinessSummary( + DateTimeOffset checkedAt, + IReadOnlyCollection dependencies) + { + var ordered = dependencies + .OrderByDescending(candidate => candidate.Required) + .ThenBy(candidate => candidate.DisplayName, StringComparer.Ordinal) + .ToArray(); + + var requiredDependencies = ordered.Where(candidate => candidate.Required).ToArray(); + var optionalDependencies = ordered.Where(candidate => !candidate.Required).ToArray(); + var blockingDependencies = requiredDependencies.Where(candidate => !string.Equals(candidate.Status, "ready", StringComparison.OrdinalIgnoreCase)).ToArray(); + var degradedOptionalDependencies = optionalDependencies.Where(candidate => !string.Equals(candidate.Status, "ready", StringComparison.OrdinalIgnoreCase)).ToArray(); + + var status = blockingDependencies.Length > 0 + ? "blocked" + : degradedOptionalDependencies.Length > 0 + ? "degraded" + : "ready"; + + var message = blockingDependencies.Length > 0 + ? $"Required dependencies blocking setup: {string.Join(", ", blockingDependencies.Select(candidate => candidate.DisplayName))}." + : degradedOptionalDependencies.Length > 0 + ? $"Required dependencies are ready. Optional services degraded: {string.Join(", ", degradedOptionalDependencies.Select(candidate => candidate.DisplayName))}." + : "Required and optional dependencies are ready."; + + return new PlatformReadinessSummary( + Status: status, + ReadyToProceed: blockingDependencies.Length == 0, + RequiredDependencyCount: requiredDependencies.Length, + RequiredReadyCount: requiredDependencies.Count(candidate => string.Equals(candidate.Status, "ready", StringComparison.OrdinalIgnoreCase)), + OptionalDependencyCount: optionalDependencies.Length, + OptionalReadyCount: optionalDependencies.Count(candidate => string.Equals(candidate.Status, "ready", StringComparison.OrdinalIgnoreCase)), + BlockingDependencyCount: blockingDependencies.Length, + Message: message, + CheckedAt: checkedAt, + Dependencies: ordered); + } + + private PlatformHealthSummary BuildSummary(PlatformReadinessSummary readiness) + { + var services = readiness.Dependencies + .Select(dependency => new PlatformHealthServiceStatus( + Service: dependency.Service, + Status: dependency.Status switch + { + "ready" => "healthy", + "degraded" => "degraded", + _ => dependency.Required ? "unavailable" : "degraded" + }, + Detail: dependency.Message, + CheckedAt: dependency.CheckedAt, + LatencyMs: dependency.LatencyMs)) .OrderBy(item => item.Service, StringComparer.Ordinal) .ToArray(); return new PlatformHealthSummary( - Status: "healthy", - IncidentCount: 0, + Status: readiness.Status switch + { + "ready" => "healthy", + "degraded" => "degraded", + _ => "unavailable" + }, + IncidentCount: readiness.Dependencies.Count(candidate => !string.Equals(candidate.Status, "ready", StringComparison.OrdinalIgnoreCase)), Services: services); } - private IReadOnlyList BuildDependencies() + private IReadOnlyList BuildIncidents(PlatformReadinessSummary readiness) { - var now = timeProvider.GetUtcNow(); - return ServiceNames - .Select(service => new PlatformDependencyStatus( - service, - Status: "ready", - Version: "unknown", - CheckedAt: now, - Message: null)) - .OrderBy(item => item.Service, StringComparer.Ordinal) + return readiness.Dependencies + .Where(candidate => !string.Equals(candidate.Status, "ready", StringComparison.OrdinalIgnoreCase)) + .Select(candidate => new PlatformIncident( + IncidentId: $"platform-readiness:{candidate.Service}", + Severity: candidate.Required ? "critical" : "warning", + Status: "open", + Summary: candidate.Message ?? $"{candidate.DisplayName} is {candidate.Status}.", + OpenedAt: candidate.CheckedAt, + UpdatedAt: candidate.CheckedAt)) + .OrderBy(candidate => candidate.Severity, StringComparer.Ordinal) + .ThenBy(candidate => candidate.IncidentId, StringComparer.Ordinal) .ToArray(); } - private IReadOnlyList BuildIncidents() + private IReadOnlyList BuildMetrics(PlatformReadinessSummary readiness) { - return Array.Empty(); - } + var requiredRatio = readiness.RequiredDependencyCount == 0 + ? 100d + : (double)readiness.RequiredReadyCount / readiness.RequiredDependencyCount * 100d; + var optionalRatio = readiness.OptionalDependencyCount == 0 + ? 100d + : (double)readiness.OptionalReadyCount / readiness.OptionalDependencyCount * 100d; - private IReadOnlyList BuildMetrics() - { - var now = timeProvider.GetUtcNow(); var metrics = new[] { new PlatformHealthMetric( - Metric: "platform.aggregate.latency_ms", - Value: 12, - Unit: "ms", - Status: "ok", - Threshold: 250, - SampledAt: now), + Metric: "platform.readiness.required_ready_percent", + Value: Math.Round(requiredRatio, 2), + Unit: "percent", + Status: readiness.BlockingDependencyCount == 0 ? "ok" : "breach", + Threshold: 100, + SampledAt: readiness.CheckedAt), new PlatformHealthMetric( - Metric: "platform.aggregate.errors_total", - Value: 0, + Metric: "platform.readiness.optional_ready_percent", + Value: Math.Round(optionalRatio, 2), + Unit: "percent", + Status: readiness.OptionalReadyCount == readiness.OptionalDependencyCount ? "ok" : "warn", + Threshold: 100, + SampledAt: readiness.CheckedAt), + new PlatformHealthMetric( + Metric: "platform.readiness.blocking_dependencies", + Value: readiness.BlockingDependencyCount, Unit: "count", - Status: "ok", - Threshold: 1, - SampledAt: now) + Status: readiness.BlockingDependencyCount == 0 ? "ok" : "breach", + Threshold: 0, + SampledAt: readiness.CheckedAt) }; return metrics .OrderBy(item => item.Metric, StringComparer.Ordinal) .ToArray(); } + + private string ResolveAuthorityHealthEndpoint() + { + var baseAddress = httpClientFactory.CreateClient("AuthorityInternal").BaseAddress?.ToString(); + var normalized = NormalizeBaseUrl(baseAddress) + ?? NormalizeBaseUrl(configuration["STELLAOPS_AUTHORITY_URL"]) + ?? platformOptions.Authority.Issuer.TrimEnd('/'); + + return CombineRelative(normalized, "/health"); + } + + private static string? NormalizeBaseUrl(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return value.Trim().TrimEnd('/'); + } + + private static string CombineRelative(string baseUrl, string relativePath) + { + var trimmedBase = baseUrl.TrimEnd('/'); + var trimmedRelative = relativePath.TrimStart('/'); + return $"{trimmedBase}/{trimmedRelative}"; + } + + private static string ResolveResponseVersion(HttpResponseMessage response) + { + if (response.Headers.TryGetValues("X-StellaOps-Version", out var versionValues)) + { + var version = versionValues.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(version)) + { + return version; + } + } + + return "unknown"; + } + + private static string NormalizeSetupStepStatus(SetupStepStatus status) => + status switch + { + SetupStepStatus.Current => "in progress", + SetupStepStatus.Pending => "pending", + SetupStepStatus.Blocked => "blocked", + SetupStepStatus.Failed => "failed", + SetupStepStatus.Passed => "passed", + SetupStepStatus.Skipped => "skipped", + _ => "unknown" + }; + + private sealed record OptionalServiceDefinition( + string Service, + string DisplayName, + string Category, + string UrlConfigKey) + { + public string? Endpoint { get; init; } + } } diff --git a/src/Platform/StellaOps.Platform.WebService/Services/PlatformSetupSecretStore.cs b/src/Platform/StellaOps.Platform.WebService/Services/PlatformSetupSecretStore.cs new file mode 100644 index 000000000..eb72f6e23 --- /dev/null +++ b/src/Platform/StellaOps.Platform.WebService/Services/PlatformSetupSecretStore.cs @@ -0,0 +1,398 @@ +// Copyright (c) StellaOps. All rights reserved. +// Licensed under BUSL-1.1. See LICENSE in the project root. + +using Microsoft.Extensions.Logging; +using Npgsql; +using StellaOps.Platform.WebService.Contracts; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace StellaOps.Platform.WebService.Services; + +public interface IPlatformSetupSecretStore +{ + Task> GetAsync(string sessionId, CancellationToken ct); + Task UpsertAsync(string sessionId, ImmutableArray secrets, CancellationToken ct); + Task DeleteAsync(string sessionId, ImmutableArray keys, CancellationToken ct); + Task DeleteSessionAsync(string sessionId, CancellationToken ct); + Task MoveSessionAsync(string sourceSessionId, string targetSessionId, CancellationToken ct); +} + +public interface IPlatformSetupSecretProtector +{ + byte[] Protect(string value); + string Unprotect(byte[] protectedValue); +} + +public sealed record PlatformSetupSecretRecord( + string Key, + string Value, + SetupStepId? StepId, + string UpdatedAtUtc); + +public sealed class InMemoryPlatformSetupSecretStore : IPlatformSetupSecretStore +{ + private readonly ConcurrentDictionary> secrets = + new(StringComparer.OrdinalIgnoreCase); + + public Task> GetAsync(string sessionId, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (!secrets.TryGetValue(sessionId, out var sessionSecrets)) + { + return Task.FromResult(ImmutableArray.Empty); + } + + var values = sessionSecrets.Values + .OrderBy(record => record.StepId?.ToString(), StringComparer.Ordinal) + .ThenBy(record => record.Key, StringComparer.OrdinalIgnoreCase) + .ToImmutableArray(); + return Task.FromResult(values); + } + + public Task UpsertAsync(string sessionId, ImmutableArray entries, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (entries.Length == 0) + { + return Task.CompletedTask; + } + + secrets.AddOrUpdate( + sessionId, + _ => entries.ToImmutableDictionary(entry => entry.Key, StringComparer.OrdinalIgnoreCase), + (_, existing) => + { + var builder = existing.ToBuilder(); + builder.KeyComparer = StringComparer.OrdinalIgnoreCase; + foreach (var entry in entries) + { + builder[entry.Key] = entry; + } + + return builder.ToImmutable(); + }); + + return Task.CompletedTask; + } + + public Task DeleteAsync(string sessionId, ImmutableArray keys, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (keys.Length == 0 || !secrets.TryGetValue(sessionId, out var sessionSecrets)) + { + return Task.CompletedTask; + } + + var builder = sessionSecrets.ToBuilder(); + builder.KeyComparer = StringComparer.OrdinalIgnoreCase; + foreach (var key in keys) + { + if (!string.IsNullOrWhiteSpace(key)) + { + builder.Remove(key.Trim()); + } + } + + if (builder.Count == 0) + { + secrets.TryRemove(sessionId, out _); + } + else + { + secrets[sessionId] = builder.ToImmutable(); + } + + return Task.CompletedTask; + } + + public Task DeleteSessionAsync(string sessionId, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + secrets.TryRemove(sessionId, out _); + return Task.CompletedTask; + } + + public Task MoveSessionAsync(string sourceSessionId, string targetSessionId, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (string.Equals(sourceSessionId, targetSessionId, StringComparison.OrdinalIgnoreCase)) + { + return Task.CompletedTask; + } + + if (secrets.TryRemove(sourceSessionId, out var sourceSecrets)) + { + secrets[targetSessionId] = sourceSecrets; + } + + return Task.CompletedTask; + } +} + +public sealed class PostgresPlatformSetupSecretStore : IPlatformSetupSecretStore +{ + private const string SelectSql = """ + SELECT + config_key, + step_id, + protected_value, + updated_at + FROM platform.setup_session_secrets + WHERE session_id = @sessionId + ORDER BY step_id NULLS FIRST, config_key; + """; + + private const string UpsertSql = """ + INSERT INTO platform.setup_session_secrets ( + session_id, + config_key, + step_id, + protected_value, + updated_at) + VALUES ( + @sessionId, + @configKey, + @stepId, + @protectedValue, + @updatedAt) + ON CONFLICT (session_id, config_key) DO UPDATE SET + step_id = EXCLUDED.step_id, + protected_value = EXCLUDED.protected_value, + updated_at = EXCLUDED.updated_at; + """; + + private const string DeleteByKeysSql = """ + DELETE FROM platform.setup_session_secrets + WHERE session_id = @sessionId + AND config_key = ANY(@keys); + """; + + private const string DeleteSessionSql = """ + DELETE FROM platform.setup_session_secrets + WHERE session_id = @sessionId; + """; + + private const string MoveSessionSql = """ + UPDATE platform.setup_session_secrets + SET session_id = @targetSessionId + WHERE session_id = @sourceSessionId; + """; + + private readonly NpgsqlDataSource dataSource; + private readonly IPlatformSetupSecretProtector protector; + private readonly ILogger logger; + + public PostgresPlatformSetupSecretStore( + NpgsqlDataSource dataSource, + IPlatformSetupSecretProtector protector, + ILogger logger) + { + this.dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + this.protector = protector ?? throw new ArgumentNullException(nameof(protector)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> GetAsync(string sessionId, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + await using var connection = await dataSource.OpenConnectionAsync(ct).ConfigureAwait(false); + await using var command = new NpgsqlCommand(SelectSql, connection); + command.Parameters.AddWithValue("sessionId", sessionId); + + try + { + await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false); + var results = ImmutableArray.CreateBuilder(); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + var key = reader.GetString(0); + var stepId = reader.IsDBNull(1) + ? (SetupStepId?)null + : Enum.Parse(reader.GetString(1), ignoreCase: true); + var protectedValue = reader.GetFieldValue(2); + var updatedAtUtc = FormatIso8601(reader.GetFieldValue(3)); + results.Add(new PlatformSetupSecretRecord( + Key: key, + Value: protector.Unprotect(protectedValue), + StepId: stepId, + UpdatedAtUtc: updatedAtUtc)); + } + + return results.ToImmutable(); + } + catch (PostgresException ex) when (ex.SqlState == "42P01") + { + logger.LogWarning("platform.setup_session_secrets table does not exist yet; returning no setup secrets."); + return ImmutableArray.Empty; + } + } + + public async Task UpsertAsync(string sessionId, ImmutableArray secrets, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (secrets.Length == 0) + { + return; + } + + await using var connection = await dataSource.OpenConnectionAsync(ct).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false); + + try + { + foreach (var secret in secrets) + { + await using var command = new NpgsqlCommand(UpsertSql, connection, transaction); + command.Parameters.AddWithValue("sessionId", sessionId); + command.Parameters.AddWithValue("configKey", secret.Key); + command.Parameters.AddWithValue("stepId", (object?)secret.StepId?.ToString() ?? DBNull.Value); + command.Parameters.AddWithValue("protectedValue", protector.Protect(secret.Value)); + command.Parameters.AddWithValue("updatedAt", ParseIso8601(secret.UpdatedAtUtc)); + await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + + await transaction.CommitAsync(ct).ConfigureAwait(false); + } + catch (PostgresException ex) when (ex.SqlState == "42P01") + { + await transaction.RollbackAsync(ct).ConfigureAwait(false); + logger.LogWarning("platform.setup_session_secrets table does not exist yet; skipping setup secret persistence."); + } + } + + public async Task DeleteAsync(string sessionId, ImmutableArray keys, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (keys.Length == 0) + { + return; + } + + await using var connection = await dataSource.OpenConnectionAsync(ct).ConfigureAwait(false); + await using var command = new NpgsqlCommand(DeleteByKeysSql, connection); + command.Parameters.AddWithValue("sessionId", sessionId); + command.Parameters.AddWithValue("keys", keys.Where(static key => !string.IsNullOrWhiteSpace(key)).ToArray()); + + try + { + await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + catch (PostgresException ex) when (ex.SqlState == "42P01") + { + logger.LogWarning("platform.setup_session_secrets table does not exist yet; skipping setup secret deletion."); + } + } + + public async Task DeleteSessionAsync(string sessionId, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + await using var connection = await dataSource.OpenConnectionAsync(ct).ConfigureAwait(false); + await using var command = new NpgsqlCommand(DeleteSessionSql, connection); + command.Parameters.AddWithValue("sessionId", sessionId); + + try + { + await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + catch (PostgresException ex) when (ex.SqlState == "42P01") + { + logger.LogWarning("platform.setup_session_secrets table does not exist yet; skipping setup secret cleanup."); + } + } + + public async Task MoveSessionAsync(string sourceSessionId, string targetSessionId, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceSessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(targetSessionId); + + if (string.Equals(sourceSessionId, targetSessionId, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await using var connection = await dataSource.OpenConnectionAsync(ct).ConfigureAwait(false); + await using var command = new NpgsqlCommand(MoveSessionSql, connection); + command.Parameters.AddWithValue("sourceSessionId", sourceSessionId); + command.Parameters.AddWithValue("targetSessionId", targetSessionId); + + try + { + await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + } + catch (PostgresException ex) when (ex.SqlState == "42P01") + { + logger.LogWarning("platform.setup_session_secrets table does not exist yet; skipping setup secret session move."); + } + } + + private static DateTimeOffset ParseIso8601(string value) => + DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + + private static string FormatIso8601(DateTimeOffset timestamp) => + timestamp.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture); +} + +public sealed class PlatformSetupSecretProtector : IPlatformSetupSecretProtector +{ + private const int NonceSize = 12; + private const int TagSize = 16; + private readonly byte[] key; + + public PlatformSetupSecretProtector(string rootSecret) + { + if (string.IsNullOrWhiteSpace(rootSecret)) + { + throw new InvalidOperationException( + "A setup secret protection key is required for durable Platform setup secret storage. " + + "Configure Platform:Setup:SecretProtectionKey, STELLAOPS_SECRETS_ENCRYPTION_KEY, or STELLAOPS_BOOTSTRAP_KEY."); + } + + key = SHA256.HashData(Encoding.UTF8.GetBytes($"stellaops-platform-setup-secrets-v1:{rootSecret.Trim()}")); + } + + public byte[] Protect(string value) + { + ArgumentNullException.ThrowIfNull(value); + + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + var plaintext = Encoding.UTF8.GetBytes(value); + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[TagSize]; + + using var aesGcm = new AesGcm(key, TagSize); + aesGcm.Encrypt(nonce, plaintext, ciphertext, tag); + + var protectedValue = new byte[NonceSize + TagSize + ciphertext.Length]; + nonce.CopyTo(protectedValue, 0); + tag.CopyTo(protectedValue, NonceSize); + ciphertext.CopyTo(protectedValue, NonceSize + TagSize); + return protectedValue; + } + + public string Unprotect(byte[] protectedValue) + { + ArgumentNullException.ThrowIfNull(protectedValue); + if (protectedValue.Length < NonceSize + TagSize) + { + throw new InvalidOperationException("Protected setup secret payload is invalid."); + } + + var nonce = protectedValue[..NonceSize]; + var tag = protectedValue[NonceSize..(NonceSize + TagSize)]; + var ciphertext = protectedValue[(NonceSize + TagSize)..]; + var plaintext = new byte[ciphertext.Length]; + + using var aesGcm = new AesGcm(key, TagSize); + aesGcm.Decrypt(nonce, ciphertext, tag, plaintext); + return Encoding.UTF8.GetString(plaintext); + } +} diff --git a/src/Platform/StellaOps.Platform.WebService/Services/PlatformSetupService.cs b/src/Platform/StellaOps.Platform.WebService/Services/PlatformSetupService.cs index 6d9084a33..918c989fc 100644 --- a/src/Platform/StellaOps.Platform.WebService/Services/PlatformSetupService.cs +++ b/src/Platform/StellaOps.Platform.WebService/Services/PlatformSetupService.cs @@ -30,8 +30,10 @@ public sealed class PlatformSetupService ImmutableArray.Create("Platform", "ReleaseOrchestrator"); private readonly IPlatformSetupStore store; + private readonly IPlatformSetupSecretStore secretStore; private readonly IEnvironmentSettingsStore envSettingsStore; private readonly PlatformMigrationAdminService migrationAdminService; + private readonly PlatformHealthService healthService; private readonly IHttpClientFactory httpClientFactory; private readonly CryptoProviderHealthService cryptoProviderHealthService; private readonly PlatformServiceOptions options; @@ -40,8 +42,10 @@ public sealed class PlatformSetupService public PlatformSetupService( IPlatformSetupStore store, + IPlatformSetupSecretStore secretStore, IEnvironmentSettingsStore envSettingsStore, PlatformMigrationAdminService migrationAdminService, + PlatformHealthService healthService, IHttpClientFactory httpClientFactory, CryptoProviderHealthService cryptoProviderHealthService, IOptions options, @@ -49,8 +53,10 @@ public sealed class PlatformSetupService ILogger logger) { this.store = store ?? throw new ArgumentNullException(nameof(store)); + this.secretStore = secretStore ?? throw new ArgumentNullException(nameof(secretStore)); this.envSettingsStore = envSettingsStore ?? throw new ArgumentNullException(nameof(envSettingsStore)); this.migrationAdminService = migrationAdminService ?? throw new ArgumentNullException(nameof(migrationAdminService)); + this.healthService = healthService ?? throw new ArgumentNullException(nameof(healthService)); this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); this.cryptoProviderHealthService = cryptoProviderHealthService ?? throw new ArgumentNullException(nameof(cryptoProviderHealthService)); this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); @@ -66,7 +72,7 @@ public sealed class PlatformSetupService var existing = await store.GetAsync(InstallationScopeKey, ct).ConfigureAwait(false); if (existing is not null && existing.Status == SetupSessionStatus.InProgress && !request.ForceRestart) { - return new SetupSessionResponse(existing); + return await BuildSessionResponseAsync(existing, ct).ConfigureAwait(false); } var now = timeProvider.GetUtcNow(); @@ -90,9 +96,14 @@ public sealed class PlatformSetupService UpdatedBy: context.ActorId, DataAsOfUtc: nowIso); + if (existing is not null && !string.Equals(existing.SessionId, session.SessionId, StringComparison.Ordinal)) + { + await secretStore.MoveSessionAsync(existing.SessionId, session.SessionId, ct).ConfigureAwait(false); + } + await store.UpsertAsync(session, ct).ConfigureAwait(false); logger.LogInformation("Created installation-scoped setup session {SessionId}.", session.SessionId); - return new SetupSessionResponse(session); + return await BuildSessionResponseAsync(session, ct).ConfigureAwait(false); } public async Task GetSessionAsync( @@ -100,7 +111,9 @@ public sealed class PlatformSetupService CancellationToken ct) { var session = await store.GetAsync(InstallationScopeKey, ct).ConfigureAwait(false); - return session is null ? null : new SetupSessionResponse(session); + return session is null + ? null + : await BuildSessionResponseAsync(session, ct).ConfigureAwait(false); } public async Task GetSessionByIdAsync( @@ -108,7 +121,9 @@ public sealed class PlatformSetupService CancellationToken ct) { var session = await store.GetBySessionIdAsync(sessionId, ct).ConfigureAwait(false); - return session is null ? null : new SetupSessionResponse(session); + return session is null + ? null + : await BuildSessionResponseAsync(session, ct).ConfigureAwait(false); } public async Task ResumeOrCreateSessionAsync( @@ -117,7 +132,7 @@ public sealed class PlatformSetupService { var existing = await store.GetAsync(InstallationScopeKey, ct).ConfigureAwait(false); return existing is not null - ? new SetupSessionResponse(existing) + ? await BuildSessionResponseAsync(existing, ct).ConfigureAwait(false) : await CreateSessionAsync(context, new CreateSetupSessionRequest(), ct).ConfigureAwait(false); } @@ -127,19 +142,20 @@ public sealed class PlatformSetupService CancellationToken ct) { var session = await RequireSessionAsync(ct).ConfigureAwait(false); - var mergedConfig = MergeConfigValues(session.DraftValues, request.ConfigValues); var nowIso = FormatIso8601(timeProvider.GetUtcNow()); + var configSnapshot = await PrepareConfigSnapshotAsync(session, request.ConfigValues, nowIso, ct).ConfigureAwait(false); var updated = session with { - DraftValues = SanitizeDraftValues(mergedConfig), + DraftValues = configSnapshot.DraftValues, UpdatedAtUtc = nowIso, UpdatedBy = context.ActorId, - DataAsOfUtc = nowIso + DataAsOfUtc = nowIso, + SecretDrafts = configSnapshot.SecretDrafts }; await store.UpsertAsync(updated, ct).ConfigureAwait(false); - return new SetupSessionResponse(updated); + return await BuildSessionResponseAsync(updated, ct).ConfigureAwait(false); } public async Task ProbeStepAsync( @@ -149,8 +165,9 @@ public sealed class PlatformSetupService { var session = await RequireSessionAsync(ct).ConfigureAwait(false); var stepDefinition = RequireStepDefinition(request.StepId); - var mergedConfig = MergeConfigValues(session.DraftValues, request.Configuration); var nowIso = FormatIso8601(timeProvider.GetUtcNow()); + var configSnapshot = await PrepareConfigSnapshotAsync(session, request.Configuration, nowIso, ct).ConfigureAwait(false); + var mergedConfig = configSnapshot.EffectiveValues; SetupProbeOutcome probe; if (!DependenciesSatisfied(session.Steps, stepDefinition)) @@ -190,9 +207,12 @@ public sealed class PlatformSetupService context.ActorId, nowIso, ReplaceStep(session.Steps, updatedStep), - SanitizeDraftValues(mergedConfig), + configSnapshot.DraftValues, completedAtUtc: session.CompletedAtUtc, - status: session.Status); + status: session.Status) with + { + SecretDrafts = configSnapshot.SecretDrafts + }; await store.UpsertAsync(updatedSession, ct).ConfigureAwait(false); return new ProbeSetupStepResponse(updatedStep, probe.Success, probe.Message, probe.SuggestedFixes); @@ -205,8 +225,9 @@ public sealed class PlatformSetupService { var session = await RequireSessionAsync(ct).ConfigureAwait(false); var stepDefinition = RequireStepDefinition(request.StepId); - var mergedConfig = MergeConfigValues(session.DraftValues, request.Configuration); var nowIso = FormatIso8601(timeProvider.GetUtcNow()); + var configSnapshot = await PrepareConfigSnapshotAsync(session, request.Configuration, nowIso, ct).ConfigureAwait(false); + var mergedConfig = configSnapshot.EffectiveValues; if (!DependenciesSatisfied(session.Steps, stepDefinition)) { @@ -230,9 +251,12 @@ public sealed class PlatformSetupService context.ActorId, nowIso, ReplaceStep(session.Steps, blockedState), - SanitizeDraftValues(mergedConfig), + configSnapshot.DraftValues, completedAtUtc: session.CompletedAtUtc, - status: session.Status); + status: session.Status) with + { + SecretDrafts = configSnapshot.SecretDrafts + }; await store.UpsertAsync(blockedSession, ct).ConfigureAwait(false); return new ExecuteSetupStepResponse( @@ -260,9 +284,12 @@ public sealed class PlatformSetupService context.ActorId, nowIso, ReplaceStep(session.Steps, failedState), - SanitizeDraftValues(mergedConfig), + configSnapshot.DraftValues, completedAtUtc: session.CompletedAtUtc, - status: SetupSessionStatus.InProgress); + status: SetupSessionStatus.InProgress) with + { + SecretDrafts = configSnapshot.SecretDrafts + }; await store.UpsertAsync(failedSession, ct).ConfigureAwait(false); return new ExecuteSetupStepResponse(failedState, false, probe.Message, probe.SuggestedFixes); @@ -287,9 +314,12 @@ public sealed class PlatformSetupService context.ActorId, nowIso, ReplaceStep(session.Steps, passedState), - SanitizeDraftValues(mergedConfig), + configSnapshot.DraftValues, completedAtUtc: session.CompletedAtUtc, - status: SetupSessionStatus.InProgress); + status: SetupSessionStatus.InProgress) with + { + SecretDrafts = configSnapshot.SecretDrafts + }; await store.UpsertAsync(updatedSession, ct).ConfigureAwait(false); return new ExecuteSetupStepResponse( @@ -335,7 +365,7 @@ public sealed class PlatformSetupService status: SetupSessionStatus.InProgress); await store.UpsertAsync(updatedSession, ct).ConfigureAwait(false); - return new SetupSessionResponse(updatedSession); + return await BuildSessionResponseAsync(updatedSession, ct).ConfigureAwait(false); } public async Task ResetStepAsync( @@ -361,7 +391,7 @@ public sealed class PlatformSetupService status: SetupSessionStatus.InProgress); await store.UpsertAsync(updatedSession, ct).ConfigureAwait(false); - return new SetupSessionResponse(updatedSession); + return await BuildSessionResponseAsync(updatedSession, ct).ConfigureAwait(false); } public async Task FinalizeSessionAsync( @@ -381,6 +411,21 @@ public sealed class PlatformSetupService $"Cannot finalize: required steps not completed: {string.Join(", ", incompleteRequired.Select(step => step.StepId))}"); } + var readiness = await healthService.GetReadinessAsync(context, includeOptional: false, ct).ConfigureAwait(false); + if (!request.Force && !readiness.Value.ReadyToProceed) + { + var blockers = readiness.Value.Dependencies + .Where(candidate => candidate.Required) + .Where(candidate => !string.Equals(candidate.Status, "ready", StringComparison.OrdinalIgnoreCase)) + .Select(candidate => string.IsNullOrWhiteSpace(candidate.Message) + ? candidate.DisplayName + : $"{candidate.DisplayName} ({candidate.Message})") + .ToArray(); + + throw new InvalidOperationException( + $"Cannot finalize: required platform dependencies are not ready: {string.Join("; ", blockers)}"); + } + var nowIso = FormatIso8601(timeProvider.GetUtcNow()); var finalStatus = incompleteRequired.Length == 0 ? SetupSessionStatus.Completed @@ -397,6 +442,7 @@ public sealed class PlatformSetupService }; await store.UpsertAsync(finalized, ct).ConfigureAwait(false); + await secretStore.DeleteSessionAsync(finalized.SessionId, ct).ConfigureAwait(false); if (finalStatus == SetupSessionStatus.Completed) { await envSettingsStore.SetAsync(SetupStateDetector.SetupCompleteKey, "true", context.ActorId ?? "setup-wizard", ct) @@ -871,6 +917,93 @@ public sealed class PlatformSetupService ? SetupStepStatus.Pending : SetupStepStatus.Failed; + private async Task HydrateSessionAsync(SetupSession session, CancellationToken ct) + { + var storedSecrets = await secretStore.GetAsync(session.SessionId, ct).ConfigureAwait(false); + return session with + { + SecretDrafts = ToSecretDraftStates(storedSecrets) + }; + } + + private async Task BuildSessionResponseAsync(SetupSession session, CancellationToken ct) + { + var hydrated = await HydrateSessionAsync(session, ct).ConfigureAwait(false); + var readiness = await healthService.GetReadinessAsync( + new PlatformRequestContext(InstallationScopeKey, hydrated.UpdatedBy ?? "setup-wizard", null), + includeOptional: false, + ct).ConfigureAwait(false); + + return new SetupSessionResponse(hydrated, readiness.Value); + } + + private async Task PrepareConfigSnapshotAsync( + SetupSession session, + IReadOnlyDictionary? incoming, + string nowIso, + CancellationToken ct) + { + var draftValues = SanitizeDraftValues(MergeConfigValues(session.DraftValues, incoming)); + var storedSecrets = await secretStore.GetAsync(session.SessionId, ct).ConfigureAwait(false); + var secretsByKey = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); + foreach (var secret in storedSecrets) + { + secretsByKey[secret.Key] = secret; + } + + var secretsToUpsert = ImmutableArray.CreateBuilder(); + var secretKeysToDelete = ImmutableArray.CreateBuilder(); + + if (incoming is not null) + { + foreach (var pair in incoming) + { + if (string.IsNullOrWhiteSpace(pair.Key) || !IsSensitiveKey(pair.Key)) + { + continue; + } + + var normalizedKey = pair.Key.Trim(); + var normalizedValue = pair.Value?.Trim() ?? string.Empty; + if (string.IsNullOrEmpty(normalizedValue)) + { + secretsByKey.Remove(normalizedKey); + secretKeysToDelete.Add(normalizedKey); + continue; + } + + var secret = new PlatformSetupSecretRecord( + Key: normalizedKey, + Value: normalizedValue, + StepId: ResolveStepIdForConfigKey(normalizedKey), + UpdatedAtUtc: nowIso); + secretsByKey[normalizedKey] = secret; + secretsToUpsert.Add(secret); + } + } + + if (secretKeysToDelete.Count > 0) + { + await secretStore.DeleteAsync(session.SessionId, secretKeysToDelete.ToImmutable(), ct).ConfigureAwait(false); + } + + if (secretsToUpsert.Count > 0) + { + await secretStore.UpsertAsync(session.SessionId, secretsToUpsert.ToImmutable(), ct).ConfigureAwait(false); + } + + var effectiveSecrets = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); + foreach (var secret in secretsByKey.Values) + { + effectiveSecrets[secret.Key] = secret.Value; + } + + return new SetupConfigSnapshot( + DraftValues: draftValues, + EffectiveValues: MergeConfigValues(draftValues, effectiveSecrets.ToImmutable()), + SecretDrafts: ToSecretDraftStates(secretsByKey.Values.ToImmutableArray())); + } + private static ImmutableDictionary MergeConfigValues( ImmutableDictionary existing, IReadOnlyDictionary? incoming) @@ -967,6 +1100,43 @@ public sealed class PlatformSetupService || normalized.EndsWith(".connectionstring", StringComparison.Ordinal); } + private static ImmutableArray ToSecretDraftStates( + ImmutableArray secrets) => + secrets + .OrderBy(secret => secret.StepId?.ToString(), StringComparer.Ordinal) + .ThenBy(secret => secret.Key, StringComparer.OrdinalIgnoreCase) + .Select(secret => new SetupSecretDraftState( + Key: secret.Key, + StepId: secret.StepId, + UpdatedAtUtc: secret.UpdatedAtUtc)) + .ToImmutableArray(); + + private static SetupStepId? ResolveStepIdForConfigKey(string key) + { + if (key.StartsWith("database.", StringComparison.OrdinalIgnoreCase)) + { + return SetupStepId.Database; + } + + if (key.StartsWith("cache.", StringComparison.OrdinalIgnoreCase)) + { + return SetupStepId.Valkey; + } + + if (key.StartsWith("users.superuser.", StringComparison.OrdinalIgnoreCase) || + key.StartsWith("authority.", StringComparison.OrdinalIgnoreCase)) + { + return SetupStepId.Admin; + } + + if (key.StartsWith("crypto.", StringComparison.OrdinalIgnoreCase)) + { + return SetupStepId.Crypto; + } + + return null; + } + private static ImmutableDictionary FilterConfigForStep( SetupStepId stepId, ImmutableDictionary config) @@ -1091,6 +1261,11 @@ public sealed class PlatformSetupService new(false, message, checkResults, ImmutableDictionary.Empty.WithComparers(StringComparer.OrdinalIgnoreCase), ImmutableArray.Empty); } + private sealed record SetupConfigSnapshot( + ImmutableDictionary DraftValues, + ImmutableDictionary EffectiveValues, + ImmutableArray SecretDrafts); + private sealed record AuthorityBootstrapUserRequest( string? Provider, string Username, diff --git a/src/Platform/StellaOps.Platform.WebService/TASKS.md b/src/Platform/StellaOps.Platform.WebService/TASKS.md index b7c2e6c75..766a59530 100644 --- a/src/Platform/StellaOps.Platform.WebService/TASKS.md +++ b/src/Platform/StellaOps.Platform.WebService/TASKS.md @@ -1,15 +1,17 @@ # Platform WebService Task Board This board mirrors active sprint tasks for this module. -Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`. +Source of truth: `docs/implplan/SPRINT_20260415_005_DOCS_platform_binaryindex_doctor_real_backend_cutover.md`. | Task ID | Status | Notes | | --- | --- | --- | | SPRINT_20260405_011-XPORT | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named Platform runtime PostgreSQL data sources for score-history and analytics ingestion/query paths. | | SPRINT_20260405_011-XPORT-VALKEY | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named Platform Valkey client construction. | | SPRINT_20260405_011-XPORT-HTTP | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named Platform identity-provider HTTP test client wiring and removed raw fallback `HttpClient` allocation. | +| OPSREAL-001 | DONE | `docs/implplan/SPRINT_20260415_005_DOCS_platform_binaryindex_doctor_real_backend_cutover.md`: Platform live runtime now fail-fast requires `Platform:Storage:PostgresConnectionString` outside `Testing`, `PlatformStartupContractTests` and `PlatformDurableRuntimeTests` both passed, and the paired BinaryIndex durable-runtime proof also passed so the shared sprint task is closed. | | SPRINT_20260410_001-NOMOCK-006 | DONE | `docs/implplan/SPRINT_20260410_001_Web_runtime_no_mocks_real_backend.md`: removed the last runtime `InMemoryScriptService` branch; Platform now reaches the owning Release Orchestrator scripts backend either directly through PostgreSQL-backed libraries or through the Release Orchestrator HTTP API. | | SPRINT_20260410_001-NOMOCK-007 | DONE | `docs/implplan/SPRINT_20260410_001_Web_runtime_no_mocks_real_backend.md`: removed the runtime Release Orchestrator environment in-memory branch; Platform now uses PostgreSQL-backed environment services or tenant/auth-aware HTTP proxying to the owning Release Orchestrator API, and the Angular release-environment client targets `/api/v1/release-orchestrator` with real target/freeze payload mapping. | +| PLATFORM-BOOT-005 | DONE | `docs/implplan/SPRINT_20260416_002_Platform_bootstrap_auth_and_integration_onboarding_hardening.md`: replaced synthetic Platform health with the shared readiness contract, added required-only setup readiness and finalize-time blocker enforcement, and exposed the full required-plus-optional readiness surface to CLI diagnostics. | | SPRINT_20260413_004-BOOTSTRAP-002 | DONE | `docs/implplan/SPRINT_20260413_004_Platform_ui_only_setup_bootstrap_closure.md`: setup wizard state is now persisted in `platform.setup_sessions`, setup mutations return structured non-500 failures for expected validation/convergence problems, and backend tests cover restart persistence plus finalize/apply semantics. | | SPRINT_20260413_004-BOOTSTRAP-003 | DONE | `docs/implplan/SPRINT_20260413_004_Platform_ui_only_setup_bootstrap_closure.md`: the browser bootstrap flow now owns only the five installation-scoped control-plane steps, while tenant onboarding remains on `/setup/*` and other authenticated surfaces. | | SPRINT_20260413_004-BOOTSTRAP-005 | DONE | `docs/implplan/SPRINT_20260413_004_Platform_ui_only_setup_bootstrap_closure.md`: the setup wizard now projects server-authoritative progress, probe does not complete steps, and `src/Web/StellaOps.Web/scripts/live-setup-wizard-state-truth-check.mjs` proves reload persistence through the live frontdoor. | diff --git a/src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/066_PlatformSetupSessionSecrets.sql b/src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/066_PlatformSetupSessionSecrets.sql new file mode 100644 index 000000000..9089a18fd --- /dev/null +++ b/src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/066_PlatformSetupSessionSecrets.sql @@ -0,0 +1,20 @@ +-- Migration: 066_PlatformSetupSessionSecrets +-- Purpose: Persist protected setup-session secret drafts separately from sanitized setup session state +-- Sprint: SPRINT_20260416_002_Platform_bootstrap_auth_and_integration_onboarding_hardening + +CREATE SCHEMA IF NOT EXISTS platform; + +CREATE TABLE IF NOT EXISTS platform.setup_session_secrets ( + session_id TEXT NOT NULL, + config_key TEXT NOT NULL, + step_id TEXT NULL, + protected_value BYTEA NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (session_id, config_key) +); + +CREATE INDEX IF NOT EXISTS ix_platform_setup_session_secrets_session_id + ON platform.setup_session_secrets (session_id); + +COMMENT ON TABLE platform.setup_session_secrets IS + 'Protected setup wizard secret drafts keyed by session and config key. Plaintext never appears in platform.setup_sessions drafts_json.'; diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/HealthEndpointsTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/HealthEndpointsTests.cs index b3854cb4b..078e7a65d 100644 --- a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/HealthEndpointsTests.cs +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/HealthEndpointsTests.cs @@ -1,10 +1,20 @@ using System; using System.Linq; +using System.Net; +using System.Net.Http; using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Http; +using Microsoft.AspNetCore.TestHost; using StellaOps.Platform.WebService.Contracts; +using StellaOps.Platform.WebService.Services; +using StellaOps.TestKit; using Xunit; -using StellaOps.TestKit; namespace StellaOps.Platform.WebService.Tests; public sealed class HealthEndpointsTests : IClassFixture @@ -24,15 +34,128 @@ public sealed class HealthEndpointsTests : IClassFixture>("/api/v1/platform/health/summary", TestContext.Current.CancellationToken); + var first = await client.GetFromJsonAsync>( + "/api/v1/platform/health/summary", + TestContext.Current.CancellationToken); Assert.NotNull(first); Assert.False(first!.Cached); - var second = await client.GetFromJsonAsync>("/api/v1/platform/health/summary", TestContext.Current.CancellationToken); + var second = await client.GetFromJsonAsync>( + "/api/v1/platform/health/summary", + TestContext.Current.CancellationToken); Assert.NotNull(second); Assert.True(second!.Cached); Assert.Equal(first.DataAsOf, second.DataAsOf); Assert.True(first.Item.Services.Select(service => service.Service) .SequenceEqual(second.Item.Services.Select(service => service.Service))); } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task Readiness_DistinguishesRequiredAndOptionalDependencies() + { + var envSettingsStore = new InMemoryEnvironmentSettingsStore(); + await envSettingsStore.SetAsync( + SetupStateDetector.SetupCompleteKey, + "true", + "health-test", + TestContext.Current.CancellationToken); + + using var customFactory = factory.WithWebHostBuilder(builder => + { + var configuration = new Dictionary + { + ["STELLAOPS_RELEASE_ORCHESTRATOR_URL"] = "http://release-orchestrator.test" + }; + + foreach (var (key, value) in configuration) + { + if (value is not null) + { + builder.UseSetting(key, value); + } + } + + builder.ConfigureAppConfiguration((_, configBuilder) => + { + configBuilder.AddInMemoryCollection(configuration); + }); + + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.AddSingleton(envSettingsStore); + + services.Configure("PlatformDependencyProbe", options => + { + options.HttpMessageHandlerBuilderActions.Clear(); + options.HttpMessageHandlerBuilderActions.Add(httpBuilder => + { + httpBuilder.PrimaryHandler = new StubHttpMessageHandler(request => + { + if (string.Equals(request.RequestUri?.Host, "release-orchestrator.test", StringComparison.OrdinalIgnoreCase)) + { + return CreateJsonResponse(HttpStatusCode.ServiceUnavailable, new { status = "down" }); + } + + return CreateJsonResponse(HttpStatusCode.OK, new { status = "healthy" }); + }); + }); + }); + }); + }); + + var tenantId = $"tenant-readiness-{Guid.NewGuid():N}"; + using var client = customFactory.CreateClient(); + client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId); + + var first = await client.GetFromJsonAsync>( + "/api/v1/platform/health/readiness", + TestContext.Current.CancellationToken); + var second = await client.GetFromJsonAsync>( + "/api/v1/platform/health/readiness", + TestContext.Current.CancellationToken); + + Assert.NotNull(first); + Assert.NotNull(second); + Assert.False(first!.Cached); + Assert.True(second!.Cached); + Assert.Equal("degraded", first.Item.Status); + Assert.True(first.Item.ReadyToProceed); + Assert.Equal(7, first.Item.RequiredDependencyCount); + Assert.Equal(first.Item.RequiredDependencyCount, first.Item.RequiredReadyCount); + Assert.Equal(1, first.Item.OptionalDependencyCount); + Assert.Equal(0, first.Item.OptionalReadyCount); + + var frontdoor = Assert.Single(first.Item.Dependencies, candidate => candidate.Service == "frontdoor"); + Assert.True(frontdoor.Required); + Assert.Equal("ready", frontdoor.Status); + + var optional = Assert.Single(first.Item.Dependencies, candidate => candidate.Service == "release-orchestrator"); + Assert.False(optional.Required); + Assert.Equal("degraded", optional.Status); + } + + private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, object payload) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") + }; + } + + private sealed class StubHttpMessageHandler : HttpMessageHandler + { + private readonly Func responseFactory; + + public StubHttpMessageHandler(Func responseFactory) + { + this.responseFactory = responseFactory; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(responseFactory(request)); + } + } } diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformDurableRuntimeTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformDurableRuntimeTests.cs new file mode 100644 index 000000000..f985964a6 --- /dev/null +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformDurableRuntimeTests.cs @@ -0,0 +1,113 @@ +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using StellaOps.Infrastructure.Postgres.Migrations; +using StellaOps.Platform.WebService.Contracts; +using StellaOps.Platform.WebService.Options; +using StellaOps.Platform.WebService.Services; +using StellaOps.ReleaseOrchestrator.Environment.Postgres; +using StellaOps.TestKit; +using StellaOps.TestKit.Fixtures; +using Xunit; + +namespace StellaOps.Platform.WebService.Tests; + +[Collection("Postgres")] +public sealed class PlatformDurableRuntimeTests +{ + private readonly PostgresFixture _fixture; + + public PlatformDurableRuntimeTests(PostgresFixture fixture) + { + _fixture = fixture; + } + + [Trait("Category", TestCategories.Integration)] + [Fact] + public async Task SetupSession_PersistsAcrossHostRestart_WithDurableStores() + { + await using var session = await _fixture.CreateDatabaseSessionAsync("platform_durable_runtime"); + const string expectedEmail = "admin@stella.local"; + + using (var factory = CreateDurableFactory(session.ConnectionString)) + { + using var client = factory.CreateClient(); + + var createResponse = await client.PostAsJsonAsync( + "/api/v1/setup/sessions", + new CreateSetupSessionRequest(), + TestContext.Current.CancellationToken); + createResponse.EnsureSuccessStatusCode(); + + var created = await client.GetFromJsonAsync( + "/api/v1/setup/sessions/current", + TestContext.Current.CancellationToken); + + Assert.NotNull(created); + + var saveDraftResponse = await client.PutAsJsonAsync( + $"/api/v1/setup/sessions/{created!.Session.SessionId}/config", + new + { + configValues = new Dictionary + { + ["users.superuser.username"] = "admin", + ["users.superuser.email"] = expectedEmail, + ["users.superuser.password"] = "Admin!23456789", + ["authority.provider"] = "local", + } + }, + TestContext.Current.CancellationToken); + saveDraftResponse.EnsureSuccessStatusCode(); + + using var scope = factory.Services.CreateScope(); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + } + + using var restartedFactory = CreateDurableFactory(session.ConnectionString); + using var restartedClient = restartedFactory.CreateClient(); + + var afterRestart = await restartedClient.GetFromJsonAsync( + "/api/v1/setup/sessions/current", + TestContext.Current.CancellationToken); + + Assert.NotNull(afterRestart); + Assert.Equal(expectedEmail, afterRestart!.Session.DraftValues["users.superuser.email"]); + Assert.False(afterRestart.Session.DraftValues.ContainsKey("users.superuser.password")); + var retainedSecret = Assert.Single(afterRestart.Session.SecretDrafts); + Assert.Equal("users.superuser.password", retainedSecret.Key); + Assert.Equal(SetupStepId.Admin, retainedSecret.StepId); + Assert.Equal(SetupSessionStatus.InProgress, afterRestart.Session.Status); + Assert.Equal(SetupStepId.Database, afterRestart.Session.CurrentStepId); + + NpgsqlConnection.ClearAllPools(); + } + + private static WebApplicationFactory CreateDurableFactory(string connectionString) + { + return new PlatformWebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseSetting("Platform:Storage:Driver", "postgres"); + builder.UseSetting("Platform:Storage:PostgresConnectionString", connectionString); + builder.UseSetting("STELLAOPS_BOOTSTRAP_KEY", "platform-setup-secret-protection-key"); + + builder.ConfigureServices(services => + { + services.AddStartupMigrations( + schemaName: "release", + moduleName: "Platform.Release", + typeof(StellaOps.Platform.Database.MigrationModuleRegistry).Assembly, + options => options.Storage.PostgresConnectionString!); + + services.AddStartupMigrations( + schemaName: "release", + moduleName: "ReleaseOrchestrator.Environment", + typeof(ReleaseEnvironmentPostgresDataSource).Assembly, + options => options.Storage.PostgresConnectionString!); + }); + }); + } +} diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformStartupContractTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformStartupContractTests.cs new file mode 100644 index 000000000..0aeeb6f85 --- /dev/null +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformStartupContractTests.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using StellaOps.Platform.WebService.Options; +using StellaOps.Platform.WebService.Services; + +namespace StellaOps.Platform.WebService.Tests; + +public sealed class PlatformStartupContractTests +{ + [Fact] + public void ProductionStartup_WithoutPlatformStorage_FailsFast() + { + using var factory = new PlatformWebApplicationFactory() + .WithWebHostBuilder(builder => builder.UseEnvironment("Production")); + + var exception = Assert.ThrowsAny(() => + { + using var client = factory.CreateClient(); + }); + + Assert.Contains( + "Platform requires Platform:Storage:PostgresConnectionString outside the Testing environment.", + exception.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public void TestingStartup_WithoutPlatformStorage_UsesTestOnlyFallbacks() + { + using var factory = new PlatformWebApplicationFactory(); + using var client = factory.CreateClient(); + using var scope = factory.Services.CreateScope(); + + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + Assert.IsType(scope.ServiceProvider.GetRequiredService()); + } +} diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformWebApplicationFactory.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformWebApplicationFactory.cs index 13144a20c..5e0f9eaea 100644 --- a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformWebApplicationFactory.cs +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformWebApplicationFactory.cs @@ -1,5 +1,9 @@ using System.Security.Claims; +using System.Net; +using System.Net.Http; +using System.Text; using System.Text.Encodings.Web; +using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -7,6 +11,7 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Http; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -21,6 +26,7 @@ public sealed class PlatformWebApplicationFactory : WebApplicationFactory { + services.Configure("AuthorityInternal", options => + { + options.HttpMessageHandlerBuilderActions.Clear(); + options.HttpMessageHandlerBuilderActions.Add(builder => + { + builder.PrimaryHandler = new StubHttpMessageHandler(request => + { + if (request.RequestUri?.AbsolutePath.EndsWith("/health", StringComparison.OrdinalIgnoreCase) == true) + { + return CreateJsonResponse(HttpStatusCode.OK, new { status = "healthy" }); + } + + if (request.RequestUri?.AbsolutePath.EndsWith("/internal/users", StringComparison.OrdinalIgnoreCase) == true) + { + return CreateJsonResponse(HttpStatusCode.OK, new { ensured = true }); + } + + return CreateJsonResponse(HttpStatusCode.OK, new { status = "ok" }); + }); + }); + }); + + services.Configure("PlatformDependencyProbe", options => + { + options.HttpMessageHandlerBuilderActions.Clear(); + options.HttpMessageHandlerBuilderActions.Add(builder => + { + builder.PrimaryHandler = new StubHttpMessageHandler(_ => + CreateJsonResponse(HttpStatusCode.OK, new { status = "healthy" })); + }); + }); + // Replace authentication with a test scheme that always succeeds. // WebApplicationFactory uses in-memory transport where RemoteIpAddress is null, // so the bypass network evaluator cannot match and JWT auth has no token issuer. @@ -189,4 +227,27 @@ public sealed class PlatformWebApplicationFactory : WebApplicationFactory responseFactory; + + public StubHttpMessageHandler(Func responseFactory) + { + this.responseFactory = responseFactory; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(responseFactory(request)); + } + } } diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/SetupEndpointsTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/SetupEndpointsTests.cs index d7e702fe7..e171f8904 100644 --- a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/SetupEndpointsTests.cs +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/SetupEndpointsTests.cs @@ -37,8 +37,9 @@ public sealed class SetupEndpointsTests : IClassFixture( @@ -93,16 +97,192 @@ public sealed class SetupEndpointsTests : IClassFixture + { + ["users.superuser.username"] = "admin", + ["users.superuser.email"] = "admin@stella.local", + ["users.superuser.password"] = "Admin!23456789", + ["authority.provider"] = "local" + } + }) + }; + request.Headers.TransferEncodingChunked = true; + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + + var resumed = await client.GetFromJsonAsync( + "/api/v1/setup/sessions/current", + TestContext.Current.CancellationToken); + + Assert.NotNull(resumed); + Assert.Equal("admin", resumed!.Session.DraftValues["users.superuser.username"]); + Assert.Equal("admin@stella.local", resumed.Session.DraftValues["users.superuser.email"]); + Assert.Equal("local", resumed.Session.DraftValues["authority.provider"]); + Assert.False(resumed.Session.DraftValues.ContainsKey("users.superuser.password")); + var retainedSecret = Assert.Single(resumed.Session.SecretDrafts); + Assert.Equal("users.superuser.password", retainedSecret.Key); + Assert.Equal(SetupStepId.Admin, retainedSecret.StepId); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task RetainedAdminSecret_RemainsRedactedInSessionReads_ButApplyCanReuseIt() + { + var sharedStore = new InMemoryPlatformSetupStore(); + var sharedSecretStore = new InMemoryPlatformSetupSecretStore(); + using var factory = CreateSetupFactory(sharedStore, sharedSecretStore, CreateAuthoritySuccessHandler()); + using var client = CreateSetupClient(factory); + + var created = await CreateSessionAsync(client); + + var saveDraftResponse = await client.PutAsJsonAsync( + $"/api/v1/setup/sessions/{created.Session.SessionId}/config", + new + { + configValues = new Dictionary + { + ["users.superuser.username"] = "admin", + ["users.superuser.email"] = "admin@stella.local", + ["users.superuser.password"] = "Admin!23456789", + ["authority.provider"] = "local", + } + }, + TestContext.Current.CancellationToken); + saveDraftResponse.EnsureSuccessStatusCode(); + + await SeedSessionAsync( + sharedStore, + WithStepStatuses( + (await client.GetFromJsonAsync( + "/api/v1/setup/sessions/current", + TestContext.Current.CancellationToken))!.Session, + currentStepId: SetupStepId.Admin, + passedSteps: + [ + SetupStepId.Database, + SetupStepId.Valkey, + SetupStepId.Migrations + ])); + + var redacted = await client.GetFromJsonAsync( + "/api/v1/setup/sessions/current", + TestContext.Current.CancellationToken); + + Assert.NotNull(redacted); + Assert.False(redacted!.Session.DraftValues.ContainsKey("users.superuser.password")); + var retainedSecret = Assert.Single(redacted.Session.SecretDrafts); + Assert.Equal("users.superuser.password", retainedSecret.Key); + Assert.Equal(SetupStepId.Admin, retainedSecret.StepId); + + var applyResponse = await client.PostAsJsonAsync( + $"/api/v1/setup/sessions/{created.Session.SessionId}/steps/admin/apply", + new + { + configValues = new Dictionary + { + ["users.superuser.username"] = "admin", + ["users.superuser.email"] = "admin@stella.local", + ["users.superuser.displayName"] = "Stella Admin", + ["authority.provider"] = "local", + } + }, + TestContext.Current.CancellationToken); + applyResponse.EnsureSuccessStatusCode(); + + using var applyDocument = await ReadJsonDocumentAsync(applyResponse); + Assert.Equal("completed", applyDocument.RootElement.GetProperty("data").GetProperty("status").GetString()); + + var afterApply = await client.GetFromJsonAsync( + "/api/v1/setup/sessions/current", + TestContext.Current.CancellationToken); + Assert.NotNull(afterApply); + Assert.Equal(SetupStepStatus.Passed, GetStep(afterApply!.Session, SetupStepId.Admin).Status); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task ApplySetupStep_AcceptsChunkedJsonBodies() + { + var sharedStore = new InMemoryPlatformSetupStore(); + using var factory = CreateSetupFactory(sharedStore, authorityHandler: CreateAuthoritySuccessHandler()); + using var client = CreateSetupClient(factory); + + var created = await CreateSessionAsync(client); + await SeedSessionAsync( + sharedStore, + WithStepStatuses( + created.Session, + currentStepId: SetupStepId.Admin, + passedSteps: + [ + SetupStepId.Database, + SetupStepId.Valkey, + SetupStepId.Migrations + ])); + + using var request = new HttpRequestMessage( + HttpMethod.Post, + $"/api/v1/setup/sessions/{created.Session.SessionId}/steps/admin/apply") + { + Content = CreateChunkedJsonContent(new + { + configValues = new Dictionary + { + ["users.superuser.username"] = "admin", + ["users.superuser.email"] = "admin@stella.local", + ["users.superuser.password"] = "Admin!23456789", + ["users.superuser.displayName"] = "Stella Admin", + ["authority.provider"] = "local" + } + }) + }; + request.Headers.TransferEncodingChunked = true; + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + + using var applyDocument = await ReadJsonDocumentAsync(response); + Assert.Equal("completed", applyDocument.RootElement.GetProperty("data").GetProperty("status").GetString()); + + var afterApply = await client.GetFromJsonAsync( + "/api/v1/setup/sessions/current", + TestContext.Current.CancellationToken); + + Assert.NotNull(afterApply); + Assert.Equal(SetupStepStatus.Passed, GetStep(afterApply!.Session, SetupStepId.Admin).Status); + } + [Trait("Category", TestCategories.Unit)] [Fact] public async Task ProbeDoesNotCompleteStep_ApplyCompletesAndFinalizeRequiresRealConvergence() { var sharedStore = new InMemoryPlatformSetupStore(); - using var factory = CreateSetupFactory(sharedStore, CreateAuthoritySuccessHandler()); + using var factory = CreateSetupFactory(sharedStore, authorityHandler: CreateAuthoritySuccessHandler()); using var client = CreateSetupClient(factory); var created = await CreateSessionAsync(client); @@ -193,12 +373,58 @@ public sealed class SetupEndpointsTests : IClassFixture + { + if (request.RequestUri?.AbsolutePath.EndsWith("/health/ready", StringComparison.OrdinalIgnoreCase) == true) + { + return CreateJsonResponse(HttpStatusCode.ServiceUnavailable, new { status = "down" }); + } + + return CreateJsonResponse(HttpStatusCode.OK, new { status = "healthy" }); + })); + using var client = CreateSetupClient(factory); + + var created = await CreateSessionAsync(client); + await SeedSessionAsync( + sharedStore, + WithStepStatuses( + created.Session, + currentStepId: SetupStepId.Crypto, + passedSteps: + [ + SetupStepId.Database, + SetupStepId.Valkey, + SetupStepId.Migrations, + SetupStepId.Admin, + SetupStepId.Crypto + ])); + + var finalizeResponse = await client.PostAsJsonAsync( + $"/api/v1/setup/sessions/{created.Session.SessionId}/finalize", + new { force = false }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, finalizeResponse.StatusCode); + var problem = await finalizeResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(problem); + Assert.Contains("required platform dependencies are not ready", problem!.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Front Door", problem.Detail ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + [Trait("Category", TestCategories.Unit)] [Fact] public async Task FailedAdminApply_MarksStepFailed_AndNonForcedFinalizeRejectsIncompleteRequiredSteps() { var sharedStore = new InMemoryPlatformSetupStore(); - using var factory = CreateSetupFactory(sharedStore, CreateAuthorityFailureHandler()); + using var factory = CreateSetupFactory(sharedStore, authorityHandler: CreateAuthorityFailureHandler()); using var client = CreateSetupClient(factory); var created = await CreateSessionAsync(client); @@ -263,7 +489,7 @@ public sealed class SetupEndpointsTests : IClassFixture + authorityHandler: new StubHttpMessageHandler(request => { if (request.Method == HttpMethod.Get && request.RequestUri?.AbsolutePath.EndsWith("/health", StringComparison.OrdinalIgnoreCase) == true) { @@ -280,9 +506,74 @@ public sealed class SetupEndpointsTests : IClassFixture + configuration: new Dictionary { - ["STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__APIKEY"] = expectedBootstrapKey + ["Authority:Bootstrap:ApiKey"] = expectedBootstrapKey + }); + using var client = CreateSetupClient(factory); + + var created = await CreateSessionAsync(client); + await SeedSessionAsync( + sharedStore, + WithStepStatuses( + created.Session, + currentStepId: SetupStepId.Admin, + passedSteps: + [ + SetupStepId.Database, + SetupStepId.Valkey, + SetupStepId.Migrations + ])); + + var applyResponse = await client.PostAsJsonAsync( + $"/api/v1/setup/sessions/{created.Session.SessionId}/steps/admin/apply", + new + { + configValues = new Dictionary + { + ["users.superuser.username"] = "admin", + ["users.superuser.email"] = "admin@stella.local", + ["users.superuser.password"] = "Admin!23456789", + } + }, + TestContext.Current.CancellationToken); + applyResponse.EnsureSuccessStatusCode(); + + using var applyDocument = await ReadJsonDocumentAsync(applyResponse); + Assert.Equal("completed", applyDocument.RootElement.GetProperty("data").GetProperty("status").GetString()); + Assert.Equal(expectedBootstrapKey, observedBootstrapKey); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task AdminApply_IgnoresEmptyBootstrapKeyBeforeAuthorityBootstrapApiKeyConfiguration() + { + const string expectedBootstrapKey = "platform-bootstrap-key"; + string? observedBootstrapKey = null; + var sharedStore = new InMemoryPlatformSetupStore(); + using var factory = CreateSetupFactory( + sharedStore, + authorityHandler: new StubHttpMessageHandler(request => + { + if (request.Method == HttpMethod.Get && request.RequestUri?.AbsolutePath.EndsWith("/health", StringComparison.OrdinalIgnoreCase) == true) + { + return CreateJsonResponse(HttpStatusCode.OK, new { status = "healthy" }); + } + + if (request.Method == HttpMethod.Post && request.RequestUri?.AbsolutePath.EndsWith("/internal/users", StringComparison.OrdinalIgnoreCase) == true) + { + observedBootstrapKey = request.Headers.TryGetValues("X-StellaOps-Bootstrap-Key", out var values) + ? values.SingleOrDefault() + : null; + return CreateJsonResponse(HttpStatusCode.OK, new { ensured = true }); + } + + return CreateJsonResponse(HttpStatusCode.NotFound, new { message = "not found" }); + }), + configuration: new Dictionary + { + ["STELLAOPS_BOOTSTRAP_KEY"] = string.Empty, + ["Authority:Bootstrap:ApiKey"] = expectedBootstrapKey }); using var client = CreateSetupClient(factory); @@ -353,13 +644,23 @@ public sealed class SetupEndpointsTests : IClassFixture CreateSetupFactory( IPlatformSetupStore? setupStore = null, + IPlatformSetupSecretStore? setupSecretStore = null, HttpMessageHandler? authorityHandler = null, + HttpMessageHandler? dependencyProbeHandler = null, IDictionary? configuration = null) { return _factory.WithWebHostBuilder(builder => { if (configuration is not null) { + foreach (var (key, value) in configuration) + { + if (value is not null) + { + builder.UseSetting(key, value); + } + } + builder.ConfigureAppConfiguration((_, configBuilder) => { configBuilder.AddInMemoryCollection(configuration); @@ -374,6 +675,12 @@ public sealed class SetupEndpointsTests : IClassFixture(); + services.AddSingleton(setupSecretStore); + } + if (authorityHandler is not null) { services.Configure("AuthorityInternal", options => @@ -385,6 +692,18 @@ public sealed class SetupEndpointsTests : IClassFixture("PlatformDependencyProbe", options => + { + options.HttpMessageHandlerBuilderActions.Clear(); + options.HttpMessageHandlerBuilderActions.Add(builder => + { + builder.PrimaryHandler = dependencyProbeHandler; + }); + }); + } }); }); } @@ -472,6 +791,13 @@ public sealed class SetupEndpointsTests : IClassFixture new StubHttpMessageHandler(request => { diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/TASKS.md b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/TASKS.md index 23d0a725b..09f00ce7e 100644 --- a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/TASKS.md +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/TASKS.md @@ -1,10 +1,12 @@ # Platform WebService Tests Task Board This board mirrors active sprint tasks for this module. -Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`. +Source of truth: `docs/implplan/SPRINT_20260415_005_DOCS_platform_binaryindex_doctor_real_backend_cutover.md`. | Task ID | Status | Notes | | --- | --- | --- | +| OPSREAL-001-T | DOING | `docs/implplan/SPRINT_20260415_005_DOCS_platform_binaryindex_doctor_real_backend_cutover.md`: `PlatformStartupContractTests` and `PlatformDurableRuntimeTests` both passed for `Testing`-only fallback plus Postgres-backed restart persistence; BinaryIndex restart-survival proof is still pending before the parent sprint task can close. | +| PLATFORM-BOOT-005-T | DONE | `docs/implplan/SPRINT_20260416_002_Platform_bootstrap_auth_and_integration_onboarding_hardening.md`: added readiness endpoint coverage plus setup finalize blocker tests for required-versus-optional readiness. | | SPRINT_20260222_051-MGC-12-T | DONE | Added `MigrationAdminEndpointsTests` covering module registry listing, module-filter validation, release-force guard, and no-connection service-unavailable responses for `/api/v1/admin/migrations/*`. | | PACK-ADM-01-T | DONE | Added/verified `PackAdapterEndpointsTests` coverage for `/api/v1/administration/{summary,identity-access,tenant-branding,notifications,usage-limits,policy-governance,trust-signing,system}` and deterministic alias ordering assertions. | | PACK-ADM-02-T | DONE | Added `AdministrationTrustSigningMutationEndpointsTests` covering trust-owner key/issuer/certificate/transparency lifecycle plus route metadata policy bindings for `platform.trust.read`, `platform.trust.write`, and `platform.trust.admin`. | diff --git a/src/Web/StellaOps.Web/src/app/app.config.ts b/src/Web/StellaOps.Web/src/app/app.config.ts index 7fab88808..17b3e93ad 100644 --- a/src/Web/StellaOps.Web/src/app/app.config.ts +++ b/src/Web/StellaOps.Web/src/app/app.config.ts @@ -96,6 +96,11 @@ import { AUDIT_BUNDLES_API_BASE_URL, AuditBundlesHttpClient, } from './core/api/audit-bundles.client'; +import { + EXPORT_CENTER_API, + EXPORT_CENTER_API_BASE_URL, + ExportCenterHttpClient, +} from './core/api/export-center.client'; import { POLICY_EXCEPTIONS_API, POLICY_EXCEPTIONS_API_BASE_URL, @@ -548,6 +553,19 @@ export const appConfig: ApplicationConfig = { provide: AUDIT_BUNDLES_API, useExisting: AuditBundlesHttpClient, }, + { + provide: EXPORT_CENTER_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return resolveApiBaseUrl(gatewayBase, '/export-center'); + }, + }, + ExportCenterHttpClient, + { + provide: EXPORT_CENTER_API, + useExisting: ExportCenterHttpClient, + }, { provide: POLICY_EXCEPTIONS_API_BASE_URL, deps: [AppConfigService], diff --git a/src/Web/StellaOps.Web/src/app/core/services/offline-mode.service.ts b/src/Web/StellaOps.Web/src/app/core/services/offline-mode.service.ts index 26bfed939..0550305b4 100644 --- a/src/Web/StellaOps.Web/src/app/core/services/offline-mode.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/services/offline-mode.service.ts @@ -116,6 +116,19 @@ export class OfflineModeService implements OnDestroy { } } + clearManifest(): void { + this.cachedManifest.set(null); + this.clearPersistedManifest(); + + if (this.isOffline()) { + this.setOfflineState({ + ...this.offlineState(), + bundleVersion: undefined, + bundleCreatedAt: undefined, + }); + } + } + /** * Check if a feature is available in offline mode */ @@ -262,6 +275,14 @@ export class OfflineModeService implements OnDestroy { } } + private clearPersistedManifest(): void { + try { + localStorage.removeItem(MANIFEST_CACHE_KEY); + } catch { + // Ignore localStorage errors + } + } + private delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } diff --git a/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/configuration-pane.component.ts b/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/configuration-pane.component.ts index 4bae21cdb..0ce6bd297 100644 --- a/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/configuration-pane.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/configuration-pane.component.ts @@ -472,13 +472,12 @@ export class ConfigurationPaneComponent implements OnInit { onTestConnection(integration: ConfiguredIntegration): void { this.state.updateIntegrationStatus(integration.id, 'checking'); + if (this.state.selectedIntegrationId() === integration.id) { + this.state.testing.set(true); + } this.api - .testConnection({ - integrationType: integration.type, - provider: integration.provider, - configValues: integration.configValues, - }) + .testConnection(integration.id) .subscribe({ next: (result) => { this.state.updateIntegrationStatus( @@ -490,10 +489,12 @@ export class ConfigurationPaneComponent implements OnInit { } else { this.state.showError(`${integration.name}: ${result.message}`); } + this.state.testing.set(false); }, - error: (err) => { + error: () => { this.state.updateIntegrationStatus(integration.id, 'error'); this.state.showError(`${integration.name}: Connection test failed`); + this.state.testing.set(false); }, }); } @@ -553,9 +554,7 @@ export class ConfigurationPaneComponent implements OnInit { onTestConnectionForSelected(): void { const integration = this.state.selectedIntegration(); if (integration) { - this.state.testing.set(true); this.onTestConnection(integration); - setTimeout(() => this.state.testing.set(false), 2000); } } diff --git a/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/integration-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/integration-detail.component.ts index e8caec4d5..f708d02fd 100644 --- a/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/integration-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/integration-detail.component.ts @@ -690,7 +690,11 @@ export class IntegrationDetailComponent { providerDef = SETTINGS_STORE_PROVIDER_DEFINITIONS.find((p) => p.id === integration.provider); } - if (providerDef) { + const hasProviderMappedValues = providerDef?.fields.some((field) => + Object.prototype.hasOwnProperty.call(integration.configValues, field.key) + ); + + if (providerDef && hasProviderMappedValues) { return providerDef.fields.map((f) => ({ key: f.key, label: f.label, diff --git a/src/Web/StellaOps.Web/src/app/features/configuration-pane/services/configuration-pane-api.service.ts b/src/Web/StellaOps.Web/src/app/features/configuration-pane/services/configuration-pane-api.service.ts index f3be674c8..626106bda 100644 --- a/src/Web/StellaOps.Web/src/app/features/configuration-pane/services/configuration-pane-api.service.ts +++ b/src/Web/StellaOps.Web/src/app/features/configuration-pane/services/configuration-pane-api.service.ts @@ -1,433 +1,492 @@ /** * @file configuration-pane-api.service.ts - * @sprint Sprint 6: Configuration Pane - * @description API service for Configuration Pane with mock implementations + * @description Truthful API composition for the configuration pane. */ -import { Injectable } from '@angular/core'; -import { Observable, of, delay, throwError } from 'rxjs'; +import { Injectable, inject } from '@angular/core'; +import { Observable, of, throwError } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; + +import { AuditEvent, IntegrationAuditDetails } from '../../../core/api/audit-log.models'; +import { AuditLogClient } from '../../../core/api/audit-log.client'; +import { IntegrationService } from '../../integration-hub/integration.service'; import { + getProviderLabel, + HealthStatus as HubHealthStatus, + Integration as HubIntegration, + IntegrationHealthResponse, + IntegrationProvider as HubIntegrationProvider, + IntegrationStatus as HubIntegrationStatus, + IntegrationType as HubIntegrationType, + TestConnectionResponse, + UpdateIntegrationRequest, +} from '../../integration-hub/integration.models'; +import { + AddIntegrationRequest, ConfiguredIntegration, ConfigurationCheck, ConfigurationHistoryEntry, - TestConnectionRequest, + IntegrationType, + RemoveIntegrationRequest, TestConnectionResult, UpdateConfigurationRequest, UpdateConfigurationResult, - AddIntegrationRequest, - RemoveIntegrationRequest, - IntegrationType, } from '../models/configuration-pane.models'; @Injectable({ providedIn: 'root', }) export class ConfigurationPaneApiService { - // Mock data for development - private readonly mockIntegrations: ConfiguredIntegration[] = [ - { - id: 'db-primary', - type: 'database', - name: 'Primary Database', - provider: 'postgresql', - description: 'Main PostgreSQL database', - status: 'connected', - healthStatus: 'healthy', - lastChecked: new Date().toISOString(), - configuredAt: new Date(Date.now() - 86400000 * 7).toISOString(), - configuredBy: 'admin@stellaops.local', - configValues: { - 'database.host': 'localhost', - 'database.port': '5432', - 'database.name': 'stellaops', - 'database.user': 'stellaops', - 'database.ssl': 'true', - }, - isPrimary: true, - }, - { - id: 'cache-primary', - type: 'cache', - name: 'Redis Cache', - provider: 'redis', - description: 'Primary Redis cache', - status: 'connected', - healthStatus: 'healthy', - lastChecked: new Date().toISOString(), - configuredAt: new Date(Date.now() - 86400000 * 7).toISOString(), - configuredBy: 'admin@stellaops.local', - configValues: { - 'cache.host': 'localhost', - 'cache.port': '6379', - 'cache.database': '0', - }, - isPrimary: true, - }, - { - id: 'vault-hashicorp', - type: 'vault', - name: 'HashiCorp Vault', - provider: 'hashicorp', - description: 'Production secrets vault', - status: 'connected', - healthStatus: 'healthy', - lastChecked: new Date().toISOString(), - configuredAt: new Date(Date.now() - 86400000 * 3).toISOString(), - configuredBy: 'admin@stellaops.local', - configValues: { - 'vault.address': 'https://vault.example.com:8200', - 'vault.namespace': 'stellaops', - 'vault.mountPath': 'secret', - }, - isPrimary: true, - }, - { - id: 'settingsstore-consul', - type: 'settingsstore', - name: 'Consul KV', - provider: 'consul', - description: 'Feature flags and configuration', - status: 'connected', - healthStatus: 'healthy', - lastChecked: new Date().toISOString(), - configuredAt: new Date(Date.now() - 86400000 * 2).toISOString(), - configuredBy: 'admin@stellaops.local', - configValues: { - 'consul.address': 'http://localhost:8500', - 'consul.prefix': 'stellaops/', - }, - isPrimary: true, - }, - ]; + private readonly integrations = inject(IntegrationService); + private readonly auditLog = inject(AuditLogClient); - private readonly mockChecks: ConfigurationCheck[] = [ - { - checkId: 'check.database.connectivity', - integrationId: 'db-primary', - name: 'Database Connectivity', - status: 'passed', - message: 'Connection established successfully', - severity: 'critical', - lastRun: new Date().toISOString(), - }, - { - checkId: 'check.database.migrations', - integrationId: 'db-primary', - name: 'Database Migrations', - status: 'passed', - message: 'All migrations applied', - severity: 'warning', - lastRun: new Date().toISOString(), - }, - { - checkId: 'check.cache.connectivity', - integrationId: 'cache-primary', - name: 'Cache Connectivity', - status: 'passed', - message: 'Redis connection active', - severity: 'critical', - lastRun: new Date().toISOString(), - }, - { - checkId: 'check.vault.connectivity', - integrationId: 'vault-hashicorp', - name: 'Vault Connectivity', - status: 'passed', - message: 'Vault connection established', - severity: 'critical', - lastRun: new Date().toISOString(), - }, - { - checkId: 'check.vault.auth', - integrationId: 'vault-hashicorp', - name: 'Vault Authentication', - status: 'passed', - message: 'Token valid', - severity: 'critical', - lastRun: new Date().toISOString(), - }, - { - checkId: 'check.consul.connectivity', - integrationId: 'settingsstore-consul', - name: 'Consul Connectivity', - status: 'passed', - message: 'Consul connection active', - severity: 'critical', - lastRun: new Date().toISOString(), - }, - ]; - - private readonly mockHistory: ConfigurationHistoryEntry[] = [ - { - id: 'hist-1', - integrationId: 'db-primary', - action: 'updated', - timestamp: new Date(Date.now() - 3600000).toISOString(), - performedBy: 'admin@stellaops.local', - previousValues: { 'database.ssl': 'false' }, - newValues: { 'database.ssl': 'true' }, - result: 'success', - message: 'Enabled SSL connection', - }, - { - id: 'hist-2', - integrationId: 'vault-hashicorp', - action: 'tested', - timestamp: new Date(Date.now() - 7200000).toISOString(), - performedBy: 'admin@stellaops.local', - result: 'success', - message: 'Connection test passed', - }, - { - id: 'hist-3', - integrationId: 'settingsstore-consul', - action: 'created', - timestamp: new Date(Date.now() - 86400000 * 2).toISOString(), - performedBy: 'admin@stellaops.local', - newValues: { - 'consul.address': 'http://localhost:8500', - 'consul.prefix': 'stellaops/', - }, - result: 'success', - message: 'Initial configuration', - }, - ]; - - /** - * Get all configured integrations - */ getIntegrations(): Observable { - return of([...this.mockIntegrations]).pipe(delay(300)); + return this.integrations.list({ + pageSize: 200, + sortBy: 'updatedAt', + sortDescending: true, + }).pipe( + map((response) => + (response.items ?? []) + .map((integration) => this.mapConfiguredIntegration(integration)) + .filter((integration): integration is ConfiguredIntegration => integration !== null) + ) + ); } - /** - * Get a specific integration by ID - */ getIntegration(integrationId: string): Observable { - const integration = this.mockIntegrations.find((i) => i.id === integrationId); - return of(integration ?? null).pipe(delay(200)); + return this.integrations.get(integrationId).pipe( + map((integration) => this.mapConfiguredIntegration(integration)), + catchError(() => of(null)) + ); } - /** - * Get integrations by type - */ getIntegrationsByType(type: IntegrationType): Observable { - const integrations = this.mockIntegrations.filter((i) => i.type === type); - return of(integrations).pipe(delay(200)); + return this.getIntegrations().pipe( + map((integrations) => integrations.filter((integration) => integration.type === type)) + ); } - /** - * Get doctor checks for all integrations - */ getChecks(): Observable { - return of([...this.mockChecks]).pipe(delay(300)); + return this.getIntegrations().pipe( + map((integrations) => integrations.map((integration) => this.buildHealthCheck(integration))) + ); } - /** - * Get doctor checks for a specific integration - */ getChecksForIntegration(integrationId: string): Observable { - const checks = this.mockChecks.filter((c) => c.integrationId === integrationId); - return of(checks).pipe(delay(200)); + return this.getIntegration(integrationId).pipe( + map((integration) => integration ? [this.buildHealthCheck(integration)] : []) + ); } - /** - * Run a specific check - */ runCheck(checkId: string): Observable { - const check = this.mockChecks.find((c) => c.checkId === checkId); - if (!check) { + const integrationId = this.parseCheckId(checkId); + if (!integrationId) { return throwError(() => new Error('Check not found')); } - // Simulate check execution - const result: ConfigurationCheck = { - ...check, - status: 'passed', - message: 'Check completed successfully', - lastRun: new Date().toISOString(), - }; - - return of(result).pipe(delay(1500)); + return this.runHealthCheck(integrationId); } - /** - * Run all checks for an integration - */ runChecksForIntegration(integrationId: string): Observable { - const checks = this.mockChecks - .filter((c) => c.integrationId === integrationId) - .map((check) => ({ - ...check, - status: 'passed' as const, - message: 'Check completed successfully', - lastRun: new Date().toISOString(), - })); - - return of(checks).pipe(delay(2000)); + return this.runHealthCheck(integrationId).pipe( + map((check) => [check]) + ); } - /** - * Test connection for an integration - */ - testConnection(request: TestConnectionRequest): Observable { - // Simulate connection test - const success = Math.random() > 0.1; // 90% success rate + testConnection(integrationId: string): Observable { + return this.integrations.testConnection(integrationId).pipe( + map((result) => this.mapTestConnectionResult(result)) + ); + } - const result: TestConnectionResult = success - ? { - success: true, - message: 'Connection established successfully', - latencyMs: Math.floor(Math.random() * 50) + 10, + updateConfiguration(request: UpdateConfigurationRequest): Observable { + return this.integrations.get(request.integrationId).pipe( + switchMap((integration) => { + const update = this.mapUpdateRequest(integration, request.configValues); + if (!update) { + return of({ + success: false, + message: 'No writable configuration fields are exposed by the current integrations API.', + requiresRestart: false, + }); } - : { - success: false, - message: 'Connection failed: Unable to reach endpoint', + + return this.integrations.update(request.integrationId, update).pipe( + map(() => ({ + success: true, + message: 'Configuration updated successfully', + requiresRestart: false, + })), + catchError((error) => of({ + success: false, + message: this.describeError(error, 'Failed to update configuration'), + requiresRestart: false, + })) + ); + }), + catchError((error) => of({ + success: false, + message: this.describeError(error, 'Failed to load integration for update'), + requiresRestart: false, + })) + ); + } + + addIntegration(_request: AddIntegrationRequest): Observable { + return throwError(() => new Error('Add integration is handled by the setup wizard.')); + } + + removeIntegration(request: RemoveIntegrationRequest): Observable<{ success: boolean; message: string }> { + return this.integrations.delete(request.integrationId).pipe( + map(() => ({ + success: true, + message: 'Integration removed successfully', + })) + ); + } + + getHistory(integrationId?: string): Observable { + return this.auditLog.getEvents({ + modules: ['integrations'], + resourceType: 'integration', + ...(integrationId ? { resourceId: integrationId } : {}), + }, undefined, 100).pipe( + map((response) => (response.items ?? []).map((event) => this.mapHistoryEntry(event))), + catchError(() => of([])) + ); + } + + refreshStatus(integrationId: string): Observable<{ status: string; healthStatus: string }> { + return this.integrations.getHealth(integrationId).pipe( + map((health) => ({ + status: this.mapConnectionStatusFromHealth(health.status), + healthStatus: this.mapHealthStatus(health.status), + })) + ); + } + + exportConfiguration(): Observable { + return this.getIntegrations().pipe( + map((integrations) => { + const payload = { + exportedAt: new Date().toISOString(), + source: 'configuration-pane', + integrations: integrations.map((integration) => ({ + id: integration.id, + type: integration.type, + provider: integration.provider, + name: integration.name, + description: integration.description ?? null, + status: integration.status, + healthStatus: integration.healthStatus, + configuredAt: integration.configuredAt, + configValues: integration.configValues, + })), }; - return of(result).pipe(delay(1500)); + return new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + }) + ); } - /** - * Update integration configuration - */ - updateConfiguration(request: UpdateConfigurationRequest): Observable { - // Simulate update - const integration = this.mockIntegrations.find((i) => i.id === request.integrationId); - if (!integration) { - return throwError(() => new Error('Integration not found')); - } - - // Update mock data - Object.assign(integration.configValues, request.configValues); - - const result: UpdateConfigurationResult = { - success: true, - message: 'Configuration updated successfully', - requiresRestart: false, - }; - - return of(result).pipe(delay(800)); - } - - /** - * Add a new integration - */ - addIntegration(request: AddIntegrationRequest): Observable { - const newIntegration: ConfiguredIntegration = { - id: `${request.type}-${Date.now()}`, - type: request.type, - name: request.name, - provider: request.provider, - status: 'unknown', - healthStatus: 'unknown', - configuredAt: new Date().toISOString(), - configValues: request.configValues, - isPrimary: request.isPrimary, - }; - - this.mockIntegrations.push(newIntegration); - return of(newIntegration).pipe(delay(500)); - } - - /** - * Remove an integration - */ - removeIntegration(request: RemoveIntegrationRequest): Observable<{ success: boolean; message: string }> { - const index = this.mockIntegrations.findIndex((i) => i.id === request.integrationId); - if (index === -1) { - return throwError(() => new Error('Integration not found')); - } - - const integration = this.mockIntegrations[index]; - - // Check if it's a required primary integration - if (integration.isPrimary && (integration.type === 'database' || integration.type === 'cache')) { - if (!request.force) { - return of({ - success: false, - message: 'Cannot remove required primary integration without force flag', - }).pipe(delay(300)); - } - } - - this.mockIntegrations.splice(index, 1); - return of({ - success: true, - message: 'Integration removed successfully', - }).pipe(delay(500)); - } - - /** - * Get configuration history - */ - getHistory(integrationId?: string): Observable { - let history = [...this.mockHistory]; - if (integrationId) { - history = history.filter((h) => h.integrationId === integrationId); - } - return of(history).pipe(delay(300)); - } - - /** - * Refresh connection status for an integration - */ - refreshStatus(integrationId: string): Observable<{ status: string; healthStatus: string }> { - return of({ - status: 'connected', - healthStatus: 'healthy', - }).pipe(delay(1000)); - } - - /** - * Export configuration as JSON - */ - exportConfiguration(): Observable { - const config = { - exportedAt: new Date().toISOString(), - version: '1.0', - integrations: this.mockIntegrations.map((i) => ({ - type: i.type, - provider: i.provider, - name: i.name, - // Exclude sensitive values - configValues: Object.fromEntries( - Object.entries(i.configValues).filter( - ([key]) => !key.includes('password') && !key.includes('secret') && !key.includes('token') - ) - ), - })), - }; - - const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }); - return of(blob).pipe(delay(200)); - } - - /** - * Validate configuration values - */ validateConfiguration( - type: IntegrationType, - provider: string, + _type: IntegrationType, + _provider: string, configValues: Record ): Observable<{ valid: boolean; errors: string[] }> { const errors: string[] = []; + const endpoint = (configValues['integration.endpoint'] ?? '').trim(); - // Basic validation - if (type === 'database') { - if (!configValues['database.host']) errors.push('Database host is required'); - if (!configValues['database.port']) errors.push('Database port is required'); - if (!configValues['database.name']) errors.push('Database name is required'); - } - - if (type === 'cache') { - if (!configValues['cache.host']) errors.push('Cache host is required'); - if (!configValues['cache.port']) errors.push('Cache port is required'); + if ('integration.endpoint' in configValues && endpoint.length === 0) { + errors.push('Integration endpoint is required'); } return of({ valid: errors.length === 0, errors, - }).pipe(delay(200)); + }); + } + + private runHealthCheck(integrationId: string): Observable { + return this.integrations.getHealth(integrationId).pipe( + map((health) => this.mapHealthResponseToCheck(integrationId, health)) + ); + } + + private mapConfiguredIntegration(integration: HubIntegration): ConfiguredIntegration | null { + const type = this.mapConfigurationType(integration); + if (!type) { + return null; + } + + return { + id: integration.id, + type, + name: integration.name, + provider: getProviderLabel(integration.provider), + description: integration.description ?? undefined, + status: this.mapConnectionStatus(integration.status, integration.lastHealthStatus), + healthStatus: this.mapHealthStatus(integration.lastHealthStatus), + lastChecked: integration.lastHealthCheckAt ?? integration.updatedAt, + configuredAt: integration.createdAt, + configuredBy: integration.createdBy ?? undefined, + configValues: this.mapConfigValues(integration), + isPrimary: false, + }; + } + + private mapConfigurationType(integration: HubIntegration): IntegrationType | null { + if (integration.provider === HubIntegrationProvider.Consul) { + return 'settingsstore'; + } + + if ( + integration.provider === HubIntegrationProvider.Vault + || integration.type === HubIntegrationType.SecretsManager + ) { + return 'vault'; + } + + if (integration.type === HubIntegrationType.Registry) { + return 'registry'; + } + + return null; + } + + private mapConfigValues(integration: HubIntegration): Record { + const configValues: Record = { + 'integration.endpoint': integration.endpoint, + }; + + if (integration.description) { + configValues['integration.description'] = integration.description; + } + + if (integration.organizationId) { + configValues['integration.organizationId'] = integration.organizationId; + } + + if (integration.tags.length > 0) { + configValues['integration.tags'] = integration.tags.join(', '); + } + + return configValues; + } + + private mapConnectionStatus(status: HubIntegrationStatus, healthStatus: HubHealthStatus): ConfiguredIntegration['status'] { + switch (status) { + case HubIntegrationStatus.Active: + return healthStatus === HubHealthStatus.Unhealthy ? 'error' : 'connected'; + case HubIntegrationStatus.Pending: + return 'checking'; + case HubIntegrationStatus.Failed: + return 'error'; + case HubIntegrationStatus.Disabled: + case HubIntegrationStatus.Archived: + return 'disconnected'; + default: + return 'unknown'; + } + } + + private mapConnectionStatusFromHealth(status: HubHealthStatus): ConfiguredIntegration['status'] { + switch (status) { + case HubHealthStatus.Healthy: + case HubHealthStatus.Degraded: + return 'connected'; + case HubHealthStatus.Unhealthy: + return 'error'; + case HubHealthStatus.Unknown: + default: + return 'unknown'; + } + } + + private mapHealthStatus(status: HubHealthStatus): ConfiguredIntegration['healthStatus'] { + switch (status) { + case HubHealthStatus.Healthy: + return 'healthy'; + case HubHealthStatus.Degraded: + return 'degraded'; + case HubHealthStatus.Unhealthy: + return 'unhealthy'; + case HubHealthStatus.Unknown: + default: + return 'unknown'; + } + } + + private buildHealthCheck(integration: ConfiguredIntegration): ConfigurationCheck { + return { + checkId: this.buildCheckId(integration.id), + integrationId: integration.id, + name: 'Connection Health', + status: this.mapCheckStatus(integration.healthStatus), + message: this.describeHealthMessage(integration), + severity: 'critical', + lastRun: integration.lastChecked, + }; + } + + private mapHealthResponseToCheck( + integrationId: string, + health: IntegrationHealthResponse + ): ConfigurationCheck { + return { + checkId: this.buildCheckId(integrationId), + integrationId, + name: 'Connection Health', + status: this.mapCheckStatus(this.mapHealthStatus(health.status)), + message: health.message ?? 'Health probe completed.', + severity: 'critical', + lastRun: health.checkedAt, + }; + } + + private mapCheckStatus( + healthStatus: ConfiguredIntegration['healthStatus'] + ): ConfigurationCheck['status'] { + switch (healthStatus) { + case 'healthy': + return 'passed'; + case 'degraded': + return 'warning'; + case 'unhealthy': + return 'failed'; + case 'unknown': + default: + return 'skipped'; + } + } + + private describeHealthMessage(integration: ConfiguredIntegration): string { + switch (integration.healthStatus) { + case 'healthy': + return 'Last known health is healthy.'; + case 'degraded': + return 'Last known health is degraded.'; + case 'unhealthy': + return 'Last known health is unhealthy.'; + default: + return 'No health result has been reported yet.'; + } + } + + private buildCheckId(integrationId: string): string { + return `health.${integrationId}`; + } + + private parseCheckId(checkId: string): string | null { + return checkId.startsWith('health.') ? checkId.slice('health.'.length) : null; + } + + private mapTestConnectionResult(result: TestConnectionResponse): TestConnectionResult { + return { + success: result.success, + message: result.message ?? (result.success ? 'Connection established successfully' : 'Connection test failed'), + latencyMs: this.parseDurationMs(result.duration), + details: result.details ?? undefined, + }; + } + + private mapUpdateRequest( + integration: HubIntegration, + configValues: Record + ): UpdateIntegrationRequest | null { + const update: UpdateIntegrationRequest = {}; + + if ( + configValues['integration.endpoint'] !== undefined + && configValues['integration.endpoint'] !== integration.endpoint + ) { + update.endpoint = configValues['integration.endpoint']; + } + + if ( + configValues['integration.description'] !== undefined + && configValues['integration.description'] !== (integration.description ?? '') + ) { + update.description = configValues['integration.description'] || null; + } + + if ( + configValues['integration.organizationId'] !== undefined + && configValues['integration.organizationId'] !== (integration.organizationId ?? '') + ) { + update.organizationId = configValues['integration.organizationId'] || null; + } + + if (configValues['integration.tags'] !== undefined) { + const nextTags = configValues['integration.tags'] + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean); + + if (nextTags.join('|') !== integration.tags.join('|')) { + update.tags = nextTags; + } + } + + return Object.keys(update).length > 0 ? update : null; + } + + private mapHistoryEntry(event: AuditEvent): ConfigurationHistoryEntry { + const details = (event.details ?? {}) as Partial; + const action = this.mapHistoryAction(event.action); + + return { + id: event.id, + integrationId: details.integrationId ?? event.resource.id, + action, + timestamp: event.timestamp, + performedBy: event.actor.name || event.actor.id || undefined, + previousValues: undefined, + newValues: details.changedFields?.length + ? Object.fromEntries(details.changedFields.map((field) => [field, 'updated'])) + : undefined, + result: event.severity === 'error' ? 'failure' : 'success', + message: details.errorMessage ?? event.description ?? event.action, + }; + } + + private mapHistoryAction(action: string): ConfigurationHistoryEntry['action'] { + if (action.includes('create')) { + return 'created'; + } + if (action.includes('delete') || action.includes('remove')) { + return 'deleted'; + } + if (action.includes('test')) { + return 'tested'; + } + return 'updated'; + } + + private parseDurationMs(duration: string | null | undefined): number | undefined { + if (!duration) { + return undefined; + } + + const trimmed = duration.trim(); + const msMatch = /^(\d+(?:\.\d+)?)ms$/i.exec(trimmed); + if (msMatch) { + return Math.round(Number(msMatch[1])); + } + + const secondsMatch = /^PT(?:(\d+(?:\.\d+)?)S)$/i.exec(trimmed); + if (secondsMatch) { + return Math.round(Number(secondsMatch[1]) * 1000); + } + + return undefined; + } + + private describeError(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + return fallback; } } diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.spec.ts deleted file mode 100644 index ec8c6ebe2..000000000 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.spec.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { computed } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; - -import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../core/api/audit-bundles.client'; -import { ViewModeService } from '../../core/services/view-mode.service'; -import { ExportCenterComponent } from './export-center.component'; -import { ExportProfile, ExportRun, StellaBundleExportResult } from './evidence-export.models'; - -describe('ExportCenterComponent', () => { - let fixture: ComponentFixture; - let component: ExportCenterComponent; - let mockRouter: { navigate: jasmine.Spy }; - let mockAuditBundlesApi: jasmine.SpyObj; - - const mockViewModeService = { - isOperator: computed(() => true), - isAuditor: computed(() => false), - }; - - const mockProfile: ExportProfile = { - id: 'test-profile-001', - name: 'Test Export Profile', - description: 'A test export profile for unit tests', - format: 'tar.gz', - includeOptions: { - sbom: true, - vulnerabilities: true, - attestations: false, - provenance: false, - vexDecisions: false, - policyEvaluations: false, - evidence: false, - rawLogs: false, - }, - schedule: { type: 'manual' }, - destinations: [], - }; - - const mockRun: ExportRun = { - id: 'test-run-001', - profileId: 'test-profile-001', - profileName: 'Test Export Profile', - status: 'running', - startedAt: new Date().toISOString(), - progress: 50, - itemsProcessed: 500, - itemsTotal: 1000, - }; - - beforeEach(async () => { - mockRouter = { - navigate: jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)), - }; - mockAuditBundlesApi = { - listBundles: jasmine.createSpy('listBundles'), - createBundle: jasmine.createSpy('createBundle'), - getBundle: jasmine.createSpy('getBundle'), - downloadBundle: jasmine.createSpy('downloadBundle'), - } as jasmine.SpyObj; - - await TestBed.configureTestingModule({ - imports: [FormsModule, ExportCenterComponent], - providers: [ - { provide: Router, useValue: mockRouter }, - { provide: AUDIT_BUNDLES_API, useValue: mockAuditBundlesApi }, - { provide: ViewModeService, useValue: mockViewModeService }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(ExportCenterComponent); - component = fixture.componentInstance; - }); - - afterEach(() => { - component?.ngOnDestroy(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display page header', () => { - fixture.detectChanges(); - const header = fixture.nativeElement.querySelector('.page-header h1'); - expect(header.textContent).toBe('Export Center'); - }); - - describe('Tab navigation', () => { - it('should default to profiles tab', () => { - fixture.detectChanges(); - expect(component.activeTab()).toBe('profiles'); - }); - - it('should switch to runs tab', () => { - fixture.detectChanges(); - component.activeTab.set('runs'); - fixture.detectChanges(); - - const runsTab = fixture.nativeElement.querySelector('.tab.active'); - expect(runsTab.textContent.trim()).toBe('Export Runs'); - }); - - it('should display profiles content when profiles tab active', () => { - fixture.detectChanges(); - const profilesGrid = fixture.nativeElement.querySelector('.profiles-grid'); - expect(profilesGrid).toBeTruthy(); - }); - - it('should display runs content when runs tab active', () => { - component.activeTab.set('runs'); - fixture.detectChanges(); - const runsList = fixture.nativeElement.querySelector('.runs-list'); - expect(runsList).toBeTruthy(); - }); - }); - - describe('Export Profiles', () => { - beforeEach(() => { - component.profiles.set([mockProfile]); - fixture.detectChanges(); - }); - - it('should display profile cards', () => { - const profileCards = fixture.nativeElement.querySelectorAll('.profile-card'); - expect(profileCards.length).toBe(1); - }); - - it('should display profile name and description', () => { - const card = fixture.nativeElement.querySelector('.profile-card'); - expect(card.textContent).toContain('Test Export Profile'); - expect(card.textContent).toContain('A test export profile'); - }); - - it('should display include option badges', () => { - const badges = fixture.nativeElement.querySelectorAll('.option-badges .badge'); - const badgeTexts = Array.from(badges).map((b: any) => b.textContent.trim()); - expect(badgeTexts).toContain('SBOM'); - expect(badgeTexts).toContain('Vulnerabilities'); - }); - - it('should open create profile modal', () => { - component.showCreateProfile(); - fixture.detectChanges(); - - expect(component.showProfileModal()).toBe(true); - expect(component.editingProfile()).toBeNull(); - - const modal = fixture.nativeElement.querySelector('.modal'); - expect(modal).toBeTruthy(); - }); - - it('should open edit profile modal with profile data', () => { - component.editProfile(mockProfile); - fixture.detectChanges(); - - expect(component.showProfileModal()).toBe(true); - expect(component.editingProfile()).toBe(mockProfile); - expect(component.profileForm.name).toBe(mockProfile.name); - }); - - it('should close modal', () => { - component.showCreateProfile(); - fixture.detectChanges(); - - component.closeModal(); - fixture.detectChanges(); - - expect(component.showProfileModal()).toBe(false); - }); - - it('should create new profile', () => { - const initialCount = component.profiles().length; - component.showCreateProfile(); - component.profileForm.name = 'New Test Profile'; - component.profileForm.description = 'New description'; - component.saveProfile(); - - expect(component.profiles().length).toBe(initialCount + 1); - expect(component.showProfileModal()).toBe(false); - }); - - it('should update existing profile', () => { - component.editProfile(mockProfile); - component.profileForm.name = 'Updated Profile Name'; - component.saveProfile(); - - const updated = component.profiles().find(p => p.id === mockProfile.id); - expect(updated?.name).toBe('Updated Profile Name'); - }); - - it('should delete profile after confirmation', () => { - spyOn(window, 'confirm').and.returnValue(true); - const initialCount = component.profiles().length; - - component.deleteProfile(mockProfile); - - expect(component.profiles().length).toBe(initialCount - 1); - }); - - it('should not delete profile if cancelled', () => { - spyOn(window, 'confirm').and.returnValue(false); - const initialCount = component.profiles().length; - - component.deleteProfile(mockProfile); - - expect(component.profiles().length).toBe(initialCount); - }); - - it('should run profile and complete a new run lifecycle', () => { - spyOn(window, 'setTimeout').and.callFake((handler: TimerHandler) => { - if (typeof handler === 'function') { - handler(); - } - return 0 as unknown as number; - }); - const initialRunCount = component.runs().length; - - component.runProfile(mockProfile); - - expect(component.runs().length).toBe(initialRunCount + 1); - expect(component.runs()[0].profileId).toBe(mockProfile.id); - expect(component.runs()[0].status).toBe('completed'); - expect(component.runs()[0].outputPath).toContain(`/exports/${mockProfile.id}-`); - expect(component.activeTab()).toBe('runs'); - }); - }); - - describe('Export Runs', () => { - beforeEach(() => { - component.runs.set([mockRun]); - component.activeTab.set('runs'); - fixture.detectChanges(); - }); - - it('should display run cards', () => { - const runCards = fixture.nativeElement.querySelectorAll('.run-card'); - expect(runCards.length).toBe(1); - }); - - it('should filter runs by status', () => { - const completedRun = { ...mockRun, id: 'r2', status: 'completed' as const }; - component.runs.set([mockRun, completedRun]); - - component.onRunFilterChange('completed'); - fixture.detectChanges(); - - expect(component.filteredRuns().length).toBe(1); - expect(component.filteredRuns()[0].status).toBe('completed'); - }); - - it('should sort runs by start time descending', () => { - const olderRun = { - ...mockRun, - id: 'r2', - startedAt: new Date(Date.now() - 3600000).toISOString(), - }; - const newerRun = { - ...mockRun, - id: 'r3', - startedAt: new Date().toISOString(), - }; - component.runs.set([olderRun, newerRun]); - fixture.detectChanges(); - - expect(component.filteredRuns()[0].id).toBe('r3'); - expect(component.filteredRuns()[1].id).toBe('r2'); - }); - - it('should display progress bar', () => { - const progressBar = fixture.nativeElement.querySelector('.progress-fill'); - expect(progressBar).toBeTruthy(); - expect(progressBar.style.width).toBe('50%'); - }); - - it('should cancel running run', () => { - component.cancelRun(mockRun); - fixture.detectChanges(); - - const updated = component.runs().find(r => r.id === mockRun.id); - expect(updated?.status).toBe('cancelled'); - }); - - it('should retry failed run', () => { - spyOn(window, 'setTimeout').and.callFake((handler: TimerHandler) => { - if (typeof handler === 'function') { - handler(); - } - return 0 as unknown as number; - }); - const failedRun = { ...mockRun, status: 'failed' as const }; - component.runs.set([failedRun]); - - const initialCount = component.runs().length; - component.retryRun(failedRun); - - expect(component.runs().length).toBe(initialCount + 1); - expect(component.runs()[0].status).toBe('completed'); - expect(component.runs()[0].outputPath).toContain(`/exports/${failedRun.profileId}-`); - }); - - it('should display error message for failed runs', () => { - const failedRun = { - ...mockRun, - status: 'failed' as const, - errorMessage: 'Connection timeout', - }; - component.runs.set([failedRun]); - fixture.detectChanges(); - - const errorDiv = fixture.nativeElement.querySelector('.run-error'); - expect(errorDiv).toBeTruthy(); - expect(errorDiv.textContent).toContain('Connection timeout'); - }); - - it('should display download button for completed runs with output', () => { - const completedRun = { - ...mockRun, - status: 'completed' as const, - outputPath: '/exports/test.tar.gz', - }; - component.runs.set([completedRun]); - fixture.detectChanges(); - - const outputDiv = fixture.nativeElement.querySelector('.run-output'); - expect(outputDiv).toBeTruthy(); - expect(outputDiv.textContent).toContain('/exports/test.tar.gz'); - }); - - it('downloads a completed run manifest', () => { - const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:run'); - const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL'); - const clickSpy = jasmine.createSpy('click'); - const nativeCreateElement = document.createElement.bind(document); - spyOn(document, 'createElement').and.callFake(((tagName: string) => { - if (tagName.toLowerCase() === 'a') { - return { - click: clickSpy, - set href(value: string) {}, - set download(value: string) {}, - } as unknown as HTMLAnchorElement; - } - return nativeCreateElement(tagName); - }) as typeof document.createElement); - - const completedRun = { - ...mockRun, - status: 'completed' as const, - outputPath: '/exports/test.tar.gz', - }; - - component.downloadRun(completedRun); - - expect(createObjectUrlSpy).toHaveBeenCalled(); - expect(clickSpy).toHaveBeenCalled(); - expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:run'); - }); - }); - - describe('StellaBundle actions', () => { - it('routes bundle details into the canonical bundles page', () => { - const result: StellaBundleExportResult = { - success: true, - bundleId: 'bundle-001', - exportId: 'stella-export-001', - artifactId: 'artifact-demo-123', - format: 'oci', - ociReference: 'oci://registry.example.com/audit@sha256:123', - checksumSha256: 'sha256:123', - sizeBytes: 1024, - includedFiles: ['bundle.json'], - durationMs: 500, - completedAt: new Date().toISOString(), - }; - - component.onViewBundleDetails(result); - - expect(mockRouter.navigate).toHaveBeenCalledWith(['/evidence/exports/bundles'], { - queryParams: { - search: result.bundleId, - artifactId: result.artifactId, - }, - }); - }); - }); - - describe('Utility methods', () => { - it('should format date correctly', () => { - const date = '2024-12-29T10:30:00Z'; - const formatted = component.formatDate(date); - expect(formatted).toContain('Dec'); - expect(formatted).toContain('29'); - }); - - it('should format datetime correctly', () => { - const datetime = '2024-12-29T10:30:00Z'; - const formatted = component.formatDateTime(datetime); - expect(formatted).toContain('Dec'); - expect(formatted).toContain('29'); - }); - }); - - describe('Lifecycle', () => { - it('should call ngOnInit without errors', () => { - expect(() => component.ngOnInit()).not.toThrow(); - }); - - it('should call ngOnDestroy without errors', () => { - expect(() => component.ngOnDestroy()).not.toThrow(); - }); - }); -}); diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.ts index f0695b42c..8699a524b 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.ts @@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component, - ViewChild, computed, inject, OnDestroy, @@ -10,12 +9,11 @@ import { signal, } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Subscription } from 'rxjs'; import { ExportDestination, ExportIncludeOptions, - ExportProfile, - ExportRun, ExportRunStatus, StellaBundleExportResult, } from './evidence-export.models'; @@ -23,7 +21,14 @@ import { StellaBundleExportButtonComponent } from './stella-bundle-export-button import { OperatorOnlyDirective } from '../../shared/directives/operator-only.directive'; import { DateFormatService } from '../../core/i18n/date-format.service'; import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; -import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component'; +import { EXPORT_CENTER_API, type ExportCenterApi } from '../../core/api/export-center.client'; +import { + type ExportProfile as ApiExportProfile, + type ExportRunEvent, + type ExportRunRequest, + type ExportRunResponse, + type ExportTargetType, +} from '../../core/api/export-center.models'; /** * Export Center Component (Sprint: SPRINT_20251229_016) * Manages export profiles and monitors export runs with SSE updates. @@ -33,9 +38,38 @@ const EXPORT_CENTER_TABS: StellaPageTab[] = [ { id: 'runs', label: 'Export Runs', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, ]; +interface RuntimeExportProfile { + id: string; + name: string; + description: string; + format: string; + includeOptions: ExportIncludeOptions; + schedule?: { + type: 'manual' | 'daily' | 'weekly' | 'monthly'; + }; + destinations: ExportDestination[]; + nextRunAt?: string; + request: ExportRunRequest; +} + +interface RuntimeExportRun { + id: string; + profileId: string; + profileName: string; + status: ExportRunStatus; + startedAt: string; + completedAt?: string; + progress: number; + itemsProcessed: number; + itemsTotal: number; + outputPath?: string; + errorMessage?: string; + request?: ExportRunRequest; +} + @Component({ selector: 'app-export-center', - imports: [FormsModule, StellaBundleExportButtonComponent, OperatorOnlyDirective, StellaPageTabsComponent, ConfirmDialogComponent], + imports: [FormsModule, StellaBundleExportButtonComponent, OperatorOnlyDirective, StellaPageTabsComponent], template: `
@@ -661,6 +663,13 @@ import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope'; white-space: pre-wrap; } + .source-preview__notice { + margin: 0.75rem 0 0; + font-size: 0.78rem; + color: var(--color-text-secondary); + line-height: 1.4; + } + .panel-actions { padding: 0.75rem 1rem; border-top: 1px solid var(--color-border-primary); @@ -966,6 +975,7 @@ export class ConflictResolutionWizardComponent implements OnInit { protected readonly currentStep = signal(0); protected readonly selectedWinner = signal<'A' | 'B' | 'merge' | null>(null); protected readonly selectedStrategy = signal(null); + protected readonly sourcePreviewNotice = 'Full source content is unavailable here. The governance conflicts contract currently exposes source metadata only.'; protected resolutionNotes = ''; protected readonly steps = [ @@ -1031,20 +1041,21 @@ export class ConflictResolutionWizardComponent implements OnInit { } protected getSourcePreview(source: PolicyConflictSource): string { - // Generate mock preview content - return JSON.stringify( - { - id: source.id, - type: source.type, - name: source.name, - version: source.version, - // Mock rule content - condition: { severity: 'high' }, - action: 'warn', - }, - null, - 2 - ); + const preview: Record = { + id: source.id, + type: source.type, + name: source.name, + }; + + if (source.version) { + preview['version'] = source.version; + } + + if (source.path) { + preview['path'] = source.path; + } + + return JSON.stringify(preview, null, 2); } protected goToStep(step: number): void { diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts deleted file mode 100644 index 62421abcf..000000000 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts +++ /dev/null @@ -1,1770 +0,0 @@ -import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { SlicePipe } from '@angular/common'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { catchError, finalize, forkJoin, map, of, switchMap, throwError } from 'rxjs'; - -import { ReleaseManagementStore } from '../release.store'; -import type { ManagedRelease } from '../../../../core/api/release-management.models'; -import { - formatDigest, - type RegistryImage, -} from '../../../../core/api/release-management.models'; -import { PlatformContextStore } from '../../../../core/context/platform-context.store'; -import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api'; -import { DEPLOYMENT_API, type CreateDeploymentRequest } from '../../../../core/api/deployment.client'; -import { - type DeploymentStrategy, - getStrategyLabel, -} from '../../../../core/api/deployment.models'; - -/* ─── Local mock types ─── */ -interface VersionOption { - id: string; - name: string; - version: string; - componentCount: number; - sealedAt: string; -} - -interface HotfixOption { - id: string; - name: string; - image: string; - tag: string; - sealedAt: string; -} - -interface PromotionStage { - name: string; - environmentId: string; -} - -/* ─── Mock data ─── */ -@Component({ - selector: 'app-create-deployment', - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [FormsModule, SlicePipe, RouterLink], - template: ` -
-
-
-

Create Deployment

-

Build a deployment plan: pick a package, choose targets, and configure how to deploy.

-
- - - Back to Deployments - -
- - @if (!linkedRelease()) { -
- -
- A release is required to create a deployment. -

Navigate to a release and use the Deploy action, or select a release from the Releases page.

-
-
- } - - @if (linkedRelease(); as rel) { -
-
- - Deploying release -
- {{ rel.name }} - {{ rel.version }} -
- } - - - @if (linkedRelease()) { - - - -
- @switch (step()) { - - - @case (1) { -
-
-

Select Package

-

Choose a sealed Version or Hotfix to deploy, or create one inline.

-
- - @if (packageLoadError(); as packageError) { -
{{ packageError }}
- } - - -
- Package type -
- - -
-
- - - @if (packageType() === 'version') { - - - @if (!selectedVersion() && getFilteredVersions().length > 0) { -
- @for (v of getFilteredVersions(); track v.id) { - - } -
- } - - @if (selectedVersion(); as v) { -
-
-
-

{{ v.name }}

- {{ v.version }} · {{ v.componentCount }} component(s) -
- -
-
-
Sealed
{{ v.sealedAt | slice:0:10 }}
-
Components
{{ v.componentCount }}
-
-
- } - - @if (!selectedVersion() && !showInlineVersion()) { - - } - - @if (showInlineVersion()) { -
-
-

New Version (inline)

- -
-
- - -
- - - - - @if (store.searchResults().length > 0 && !inlineSelectedImage()) { -
- @for (img of store.searchResults(); track img.repository) { - - } -
- } - - @if (inlineSelectedImage(); as img) { -
- @for (d of img.digests; track d.digest) { - - } -
- - } - - @if (inlineComponents.length > 0) { -
- @for (c of inlineComponents; track c.name + c.digest; let i = $index) { -
- {{ c.name }} - {{ fmtDigest(c.digest) }} - -
- } -
- } - - -
- } - } - - - @if (packageType() === 'hotfix') { - - - @if (!selectedHotfix() && getFilteredHotfixes().length > 0) { -
- @for (h of getFilteredHotfixes(); track h.id) { - - } -
- } - - @if (selectedHotfix(); as h) { -
-
-
- HOTFIX -

{{ h.name }}

- {{ h.tag }} -
- -
-
-
Image
{{ h.image }}
-
Sealed
{{ h.sealedAt | slice:0:10 }}
-
-
- } - - @if (!selectedHotfix() && !showInlineHotfix()) { - - } - - @if (showInlineHotfix()) { -
-
-

New Hotfix (inline)

- -
- - - - @if (store.searchResults().length > 0 && !inlineHotfixImage()) { -
- @for (img of store.searchResults(); track img.repository) { - - } -
- } - - @if (inlineHotfixImage(); as img) { -
- @for (d of img.digests; track d.digest) { - - } -
- - - } -
- } - } -
- } - - - @case (2) { -
-
-

Deployment Targets

-

- @if (packageType() === 'version') { - Select regions, environments, and configure promotion stages. - } @else { - Select the target region and environment for this hotfix. - } -

-
- - -
- {{ packageType() === 'hotfix' ? 'Region' : 'Regions' }} -
- @for (region of platformCtx.regions(); track region.regionId) { - - } - @if (platformCtx.regions().length === 0) { - No regions configured in platform context - } -
-
- - -
- {{ packageType() === 'hotfix' ? 'Target environment' : 'Environments' }} -
- @for (env of getFilteredEnvironments(); track env.environmentId) { - - } - @if (getFilteredEnvironments().length === 0) { - - {{ targetRegions().length > 0 ? 'No environments found for selected regions' : 'Select a region to see available environments' }} - - } -
-
- - - @if (packageType() === 'version') { -
-
- Promotion stages * - -
-
- @for (stage of promotionStages; track $index; let i = $index) { -
- {{ i + 1 }} - - - - @if (promotionStages.length > 1) { - - } -
- } -
-
- } - - - @if (packageType() === 'hotfix') { -
- - Hotfix will deploy directly to the selected environment, bypassing promotion stages. -
- } - - - @if (targetRegions().length > 0 || targetEnvironments().length > 0) { -
- Selected targets -
- @for (regionId of targetRegions(); track regionId) { - {{ regionDisplayName(regionId) }} - } - @for (envId of targetEnvironments(); track envId) { - {{ environmentDisplayName(envId) }} - } -
-
- } -
- } - - - @case (3) { -
-
-

Deployment Strategy

-

Configure how the deployment will roll out to target environments.

-
- - - - -
- - - Strategy Configuration - -
- @switch (deploymentStrategy) { - @case ('rolling') { -
- - -
-
- - -
-
- - -
- } - @case ('canary') { -
-
- Canary stages - -
- @for (stage of strategyConfig.canary.stages; track $index; let i = $index) { -
- {{ i + 1 }} - - - - @if (strategyConfig.canary.stages.length > 1) { - - } -
- } -
-
- - -
- } - @case ('blue_green') { -
- - -
-
- - -
- } - @case ('all_at_once') { -
- - -
- - } - } -
-
-
- } - - - @case (4) { -
-
-

Review & Create

-

Verify all deployment parameters before creating.

-
- -
- -
-
- -

Package

-
-
-
Type
{{ packageType() }}
- @if (packageType() === 'version' && selectedVersion(); as v) { -
Name
{{ v.name }}
-
Version
{{ v.version }}
-
Components
{{ v.componentCount }}
- } - @if (packageType() === 'hotfix' && selectedHotfix(); as h) { -
Name
{{ h.name }}
-
Image
{{ h.image }}
-
Tag
{{ h.tag }}
- } -
-
- - -
-
- -

Targets

-
-
-
Regions
{{ getTargetRegionNames().length > 0 ? getTargetRegionNames().join(', ') : 'none selected' }}
-
Environments
{{ getTargetEnvironmentNames().length > 0 ? getTargetEnvironmentNames().join(', ') : 'none selected' }}
- @if (packageType() === 'version') { -
Stages
{{ promotionStages.length }} stage(s): {{ promotionStageNames() }}
- } - @if (packageType() === 'hotfix') { -
Mode
Direct deployment (no promotion)
- } -
-
- - -
-
- -

Strategy

-
-
-
Strategy
{{ getStrategyLabel() }}
- @switch (deploymentStrategy) { - @case ('rolling') { -
Batch size
{{ strategyConfig.rolling.batchSize }} ({{ strategyConfig.rolling.batchSizeType }})
-
Batch delay
{{ strategyConfig.rolling.batchDelay }}s
-
Stabilization
{{ strategyConfig.rolling.stabilizationTime }}s
-
Max failed
{{ strategyConfig.rolling.maxFailedBatches === 0 ? 'fail on first' : strategyConfig.rolling.maxFailedBatches }}
- } - @case ('canary') { -
Stages
{{ strategyConfig.canary.stages.length }} stage(s)
-
Error threshold
{{ strategyConfig.canary.errorRateThreshold }}%
-
Latency limit
{{ strategyConfig.canary.latencyThreshold }}ms
- } - @case ('blue_green') { -
Switchover
{{ strategyConfig.blueGreen.switchoverMode }}
-
Warmup
{{ strategyConfig.blueGreen.warmupPeriod }}s
-
Keepalive
{{ strategyConfig.blueGreen.blueKeepalive }}min
- } - @case ('all_at_once') { -
Concurrency
{{ strategyConfig.allAtOnce.maxConcurrency === 0 ? 'unlimited' : strategyConfig.allAtOnce.maxConcurrency }}
-
On failure
{{ strategyConfig.allAtOnce.failureBehavior }}
- } - } -
-
-
- - -
- } - } -
- - @if (submitError(); as err) { - - } - -
- -
- Step {{ step() }} of 4 - - @if (step() < 4) { - - } @else { - - } -
- } -
- `, - styles: [` - .create-deployment { display: grid; gap: 0.75rem; max-width: 820px; margin: 0 auto; } - - /* Release required gate */ - .release-required { - display: flex; align-items: flex-start; gap: 0.75rem; padding: 1.25rem; - border: 1px solid var(--color-status-warning-border, var(--color-status-warning)); border-radius: var(--radius-lg); - background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-text-primary); - } - .release-required svg { flex-shrink: 0; color: var(--color-status-warning, #C89820); margin-top: 0.1rem; } - .release-required__text { display: grid; gap: 0.25rem; } - .release-required__text strong { font-size: var(--font-size-sm, 0.75rem); } - .release-required__text p { margin: 0; font-size: var(--font-size-sm, 0.75rem); color: var(--color-text-secondary); } - .release-required__text a { color: var(--color-text-link); text-decoration: none; } - .release-required__text a:hover { text-decoration: underline; } - - /* Release context card */ - .release-context-card { - display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0.85rem; - border: 1px solid var(--color-brand-primary-20, var(--color-border-secondary)); - border-radius: var(--radius-lg); background: var(--color-brand-primary-10, var(--color-surface-subtle)); - } - .release-context-card__header { display: flex; align-items: center; gap: 0.3rem; font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-secondary); } - .release-context-card strong { font-size: var(--font-size-sm, 0.75rem); } - .release-context-card__version { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-secondary); font-family: var(--font-family-mono, monospace); } - - /* Header */ - .wizard-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; } - .wizard-header h1 { margin: 0; font-size: var(--font-size-xl, 1.25rem); font-weight: var(--font-weight-semibold); line-height: var(--line-height-tight, 1.25); } - .wizard-header__sub { margin: 0.2rem 0 0; color: var(--color-text-secondary); font-size: var(--font-size-sm, 0.75rem); } - .btn-back { - display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.35rem 0.65rem; - border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - background: var(--color-surface-primary); color: var(--color-text-secondary); - font-size: var(--font-size-sm, 0.75rem); text-decoration: none; white-space: nowrap; - transition: color 140ms ease, border-color 140ms ease; - } - .btn-back:hover { color: var(--color-text-primary); border-color: var(--color-border-secondary); } - - /* Stepper */ - .stepper { display: flex; align-items: center; gap: 0; padding: 0.5rem 0; } - .stepper__line { flex: 1; height: 2px; background: var(--color-border-primary); transition: background 200ms ease; } - .stepper__line.done { background: var(--color-status-success-text); } - .stepper__step { - display: flex; flex-direction: column; align-items: center; gap: 0.35rem; - background: none; border: none; padding: 0 0.25rem; cursor: pointer; transition: opacity 140ms ease; - } - .stepper__step:disabled { cursor: default; opacity: 0.5; } - .stepper__circle { - display: flex; align-items: center; justify-content: center; - width: 32px; height: 32px; border-radius: 50%; - border: 2px solid var(--color-border-primary); background: var(--color-surface-primary); - font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-semibold); - color: var(--color-text-secondary); transition: border-color 140ms ease, background 140ms ease, color 140ms ease; - } - .stepper__step.active .stepper__circle { - border-color: var(--color-brand-primary); background: var(--color-brand-primary); - color: var(--color-btn-primary-text, #fff); box-shadow: 0 0 0 3px var(--color-brand-primary-10, rgba(180, 140, 50, 0.15)); - } - .stepper__step.done .stepper__circle { border-color: var(--color-status-success-text); background: var(--color-status-success-bg); color: var(--color-status-success-text); } - .stepper__label { font-size: var(--font-size-xs, 0.6875rem); font-weight: var(--font-weight-medium); color: var(--color-text-muted); white-space: nowrap; } - .stepper__step.active .stepper__label { color: var(--color-text-primary); font-weight: var(--font-weight-semibold); } - .stepper__step.done .stepper__label { color: var(--color-status-success-text); } - - /* Wizard body */ - .wizard-body { border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); background: var(--color-surface-primary); padding: 1.25rem; } - .step-panel { display: grid; gap: 0.85rem; } - .step-intro { margin-bottom: 0.25rem; } - .step-intro h2 { margin: 0; font-size: var(--font-size-md, 1rem); font-weight: var(--font-weight-semibold); } - .step-intro p { margin: 0.2rem 0 0; font-size: var(--font-size-sm, 0.75rem); color: var(--color-text-secondary); line-height: var(--line-height-relaxed, 1.625); } - - /* Form fields */ - .form-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } - .field { display: grid; gap: 0.3rem; } - .field__label { font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-medium); color: var(--color-text-primary); } - .field__label abbr { color: var(--color-status-error-text); text-decoration: none; } - .field__hint { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-muted); } - /* Type toggle */ - .type-toggle-row { display: flex; align-items: center; gap: 0.75rem; } - .toggle-pair { display: inline-flex; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); overflow: hidden; } - .toggle-pair__btn { - padding: 0.35rem 0.65rem; font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-medium); - border: none; background: var(--color-surface-primary); color: var(--color-text-secondary); cursor: pointer; transition: background 140ms ease, color 140ms ease; - } - .toggle-pair__btn + .toggle-pair__btn { border-left: 1px solid var(--color-border-primary); } - .toggle-pair__btn--active { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } - .toggle-pair__btn:hover:not(.toggle-pair__btn--active) { background: var(--color-surface-elevated); } - - /* Search */ - .search-input-wrap { position: relative; display: flex; align-items: center; } - .search-input-wrap__icon { position: absolute; left: 0.6rem; color: var(--color-text-muted); pointer-events: none; } - .search-input-wrap__input { padding-left: 2rem; } - .search-results { display: grid; gap: 0.3rem; max-height: 200px; overflow: auto; } - .search-results--compact { max-height: 150px; } - .search-item { - border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; - display: grid; gap: 0.15rem; text-align: left; cursor: pointer; - background: var(--color-surface-primary); color: var(--color-text-primary); font-size: var(--font-size-sm, 0.75rem); - transition: border-color 140ms ease, background 140ms ease; - } - .search-item:hover { border-color: var(--color-brand-primary); background: var(--color-surface-elevated); } - .search-item span, .search-item code { color: var(--color-text-secondary); font-size: var(--font-size-xs, 0.6875rem); } - .search-item__row { display: flex; align-items: center; gap: 0.5rem; } - .search-item__meta { color: var(--color-text-muted); font-size: var(--font-size-xs, 0.6875rem); } - - /* Selected card */ - .selected-card { - border: 1px solid var(--color-brand-primary-20, var(--color-border-secondary)); border-radius: var(--radius-lg); - padding: 0.75rem; background: var(--color-brand-primary-10, var(--color-surface-subtle)); - } - .selected-card--hotfix { border-color: var(--color-status-warning-border, rgba(245, 158, 11, 0.35)); background: var(--color-status-warning-bg, rgba(245, 158, 11, 0.06)); } - .selected-card__header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem; } - .selected-card__header h3 { margin: 0; font-size: var(--font-size-base, 0.8125rem); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); } - .selected-card__sub { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-secondary); } - .selected-card__dl { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 0.75rem; margin: 0; font-size: var(--font-size-sm, 0.75rem); } - .selected-card__dl dt { color: var(--color-text-secondary); font-weight: var(--font-weight-medium); } - .selected-card__dl dd { margin: 0; color: var(--color-text-primary); } - .hotfix-badge { - display: inline-block; padding: 0.1rem 0.4rem; margin-bottom: 0.25rem; - font-size: var(--font-size-xs, 0.6875rem); font-weight: var(--font-weight-bold); - letter-spacing: 0.06em; border-radius: var(--radius-sm); - background: var(--color-status-warning, #e67e22); color: #fff; - } - - /* Inline create */ - .inline-create { - display: grid; gap: 0.65rem; padding: 0.85rem; - border: 1px dashed var(--color-border-secondary, var(--color-border-primary)); border-radius: var(--radius-lg); - background: var(--color-surface-secondary); - } - .inline-create--hotfix { border-color: var(--color-status-warning-border, rgba(245, 158, 11, 0.35)); } - .inline-create__header { display: flex; justify-content: space-between; align-items: center; } - .inline-create__header h3 { margin: 0; font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); } - - /* Digest options */ - .digest-options { display: grid; gap: 0.3rem; } - .digest-option { - display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; - border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.4rem 0.6rem; - font-size: var(--font-size-sm, 0.75rem); cursor: pointer; - background: var(--color-surface-primary); color: var(--color-text-primary); transition: border-color 140ms ease, background 140ms ease; - } - .digest-option:hover { border-color: var(--color-brand-primary); } - .digest-option--selected { border-color: var(--color-brand-primary); background: var(--color-surface-elevated); box-shadow: 0 0 0 2px var(--color-focus-ring); } - .digest-option__tag { font-weight: var(--font-weight-semibold); min-width: 70px; } - .digest-option code { font-family: var(--font-family-mono, ui-monospace, monospace); color: var(--color-text-secondary); font-size: var(--font-size-xs, 0.6875rem); } - - /* Inline component list */ - .inline-component-list { display: grid; gap: 0.25rem; } - .inline-component-item { - display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0.5rem; - border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - font-size: var(--font-size-sm, 0.75rem); - } - .inline-component-item code { font-family: var(--font-family-mono, ui-monospace, monospace); color: var(--color-text-muted); font-size: var(--font-size-xs, 0.6875rem); flex: 1; } - - /* Link button */ - .btn-link { - background: none; border: none; padding: 0; color: var(--color-text-link, var(--color-brand-primary)); - font-size: var(--font-size-sm, 0.75rem); cursor: pointer; text-decoration: underline; text-underline-offset: 2px; - } - .btn-link:hover { opacity: 0.8; } - - /* Target section */ - .target-section { display: grid; gap: 0.35rem; } - .chip-selector { display: flex; flex-wrap: wrap; gap: 0.35rem; } - .chip-toggle { - display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.3rem 0.65rem; - border: 1px solid var(--color-border-primary); border-radius: var(--radius-full); - background: var(--color-surface-primary); color: var(--color-text-secondary); - font-size: var(--font-size-sm, 0.75rem); cursor: pointer; - transition: border-color 140ms ease, background 140ms ease, color 140ms ease, box-shadow 140ms ease; - } - .chip-toggle:hover { border-color: var(--color-brand-primary); color: var(--color-text-primary); } - .chip-toggle--active { - border-color: var(--color-brand-primary); background: var(--color-brand-primary); - color: var(--color-btn-primary-text, #fff); box-shadow: 0 0 0 2px var(--color-brand-primary-10, rgba(180, 140, 50, 0.15)); - } - .chip-toggle--active:hover { color: var(--color-btn-primary-text, #fff); } - .chip-toggle__region-hint { font-size: var(--font-size-xs, 0.6875rem); opacity: 0.7; } - .chip-empty-hint { font-size: var(--font-size-sm, 0.75rem); color: var(--color-text-muted); font-style: italic; padding: 0.25rem 0; } - - /* Promotion stages */ - .stages-header { display: flex; align-items: center; justify-content: space-between; } - .stages-list { display: grid; gap: 0.4rem; } - .stage-row { - display: grid; grid-template-columns: 24px 16px 1fr 1fr auto; gap: 0.5rem; align-items: end; - border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.5rem; background: var(--color-surface-primary); - } - .stage-row__num { - display: flex; align-items: center; justify-content: center; - width: 22px; height: 22px; border-radius: var(--radius-full); - background: var(--color-surface-secondary); color: var(--color-text-muted); - font-size: var(--font-size-xs, 0.6875rem); font-weight: var(--font-weight-semibold); align-self: center; - } - .stage-row__arrow { color: var(--color-text-muted); align-self: center; } - - /* Warning banner */ - .warning-banner { - display: flex; align-items: center; gap: 0.6rem; padding: 0.65rem 0.85rem; - background: var(--color-status-warning-bg, rgba(245, 158, 11, 0.08)); - border: 1px solid var(--color-status-warning-border, rgba(245, 158, 11, 0.25)); - border-radius: var(--radius-md); font-size: var(--font-size-sm, 0.75rem); - color: var(--color-status-warning-text, #f59e0b); - } - .warning-banner svg { flex-shrink: 0; } - - /* Target summary */ - .target-summary { - display: grid; gap: 0.35rem; padding: 0.65rem 0.75rem; - border: 1px solid var(--color-brand-primary-20, var(--color-border-secondary)); - border-radius: var(--radius-lg); background: var(--color-brand-primary-10, var(--color-surface-subtle)); - } - .target-summary__chips { display: flex; flex-wrap: wrap; gap: 0.3rem; } - .target-chip { - display: inline-flex; align-items: center; padding: 0.2rem 0.5rem; - border-radius: var(--radius-full); font-size: var(--font-size-xs, 0.6875rem); font-weight: var(--font-weight-medium); - } - .target-chip--region { background: var(--color-status-info-bg, rgba(59, 130, 246, 0.1)); color: var(--color-status-info-text, #3b82f6); border: 1px solid var(--color-status-info-border, rgba(59, 130, 246, 0.25)); } - .target-chip--env { background: var(--color-status-success-bg); color: var(--color-status-success-text); border: 1px solid var(--color-status-success-border, rgba(34, 197, 94, 0.25)); } - - /* Strategy config */ - .strategy-config { border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); background: var(--color-surface-secondary); } - .strategy-config__summary { - display: flex; align-items: center; gap: 0.4rem; padding: 0.55rem 0.75rem; - font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-semibold); - color: var(--color-text-primary); cursor: pointer; user-select: none; list-style: none; - } - .strategy-config__summary::-webkit-details-marker { display: none; } - .strategy-config__summary::before { - content: ''; display: inline-block; width: 6px; height: 6px; - border-right: 1.5px solid var(--color-text-muted); border-bottom: 1.5px solid var(--color-text-muted); - transform: rotate(-45deg); transition: transform 0.15s ease; flex-shrink: 0; - } - details[open] > .strategy-config__summary::before { transform: rotate(45deg); } - .strategy-config__summary svg { color: var(--color-text-secondary); } - .strategy-config__body { display: grid; gap: 0.75rem; padding: 0 0.75rem 0.75rem; border-top: 1px solid var(--color-border-primary); padding-top: 0.75rem; } - - /* Canary stages */ - .canary-stages { display: grid; gap: 0.5rem; } - .canary-stages__header { display: flex; align-items: center; justify-content: space-between; } - .canary-stage-row { - display: grid; grid-template-columns: 24px 1fr 1fr 1fr auto; gap: 0.5rem; align-items: end; - border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.5rem; background: var(--color-surface-primary); - } - .canary-stage-row--ab { grid-template-columns: 24px 1.2fr 0.6fr 0.6fr 0.8fr auto; } - .canary-stage-row__num { - display: flex; align-items: center; justify-content: center; - width: 22px; height: 22px; border-radius: var(--radius-full); - background: var(--color-surface-secondary); color: var(--color-text-muted); - font-size: var(--font-size-xs, 0.6875rem); font-weight: var(--font-weight-semibold); align-self: center; - } - - /* Review cards */ - .review-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } - .review-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 0.85rem; background: var(--color-surface-primary); } - .review-card--wide { grid-column: 1 / -1; } - .review-card__header { - display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.6rem; padding-bottom: 0.5rem; - border-bottom: 1px solid var(--color-border-primary); color: var(--color-text-secondary); - } - .review-card__header h3 { margin: 0; font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); text-transform: none; letter-spacing: normal; } - .review-card__dl { display: grid; grid-template-columns: auto 1fr; gap: 0.3rem 0.75rem; margin: 0; font-size: var(--font-size-sm, 0.75rem); } - .review-card__dl dt { color: var(--color-text-secondary); font-weight: var(--font-weight-medium); } - .review-card__dl dd { margin: 0; color: var(--color-text-primary); } - .review-card__dl code { font-family: var(--font-family-mono, ui-monospace, monospace); font-size: var(--font-size-xs, 0.6875rem); background: var(--color-surface-secondary); padding: 0.1rem 0.35rem; border-radius: var(--radius-sm); } - .review-badge { - display: inline-block; padding: 0.05rem 0.4rem; border: 1px solid var(--color-border-primary); - border-radius: var(--radius-full); font-size: var(--font-size-xs, 0.6875rem); text-transform: uppercase; letter-spacing: 0.03em; - } - - /* Seal confirm */ - .seal-confirm { - display: flex; align-items: flex-start; gap: 0.6rem; padding: 0.75rem; - border: 1px solid var(--color-brand-primary-20, var(--color-border-secondary)); - border-radius: var(--radius-lg); background: var(--color-brand-primary-10, var(--color-surface-subtle)); cursor: pointer; - } - .seal-confirm input { width: auto; margin-top: 0.15rem; } - .seal-confirm__text { display: grid; gap: 0.15rem; } - .seal-confirm__text strong { font-size: var(--font-size-sm, 0.75rem); color: var(--color-text-primary); } - .seal-confirm__text span { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-secondary); } - - /* Error */ - .wizard-error { - display: flex; align-items: center; gap: 0.5rem; padding: 0.55rem 0.75rem; - border: 1px solid var(--color-status-error-border); border-radius: var(--radius-md); - background: var(--color-status-error-bg); color: var(--color-status-error-text); - font-size: var(--font-size-sm, 0.75rem); - } - .wizard-error svg { flex-shrink: 0; } - - /* Footer */ - .wizard-actions { display: flex; align-items: center; gap: 0.5rem; } - .wizard-actions__spacer { flex: 1; } - .wizard-actions__step-label { font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-muted); margin-right: 0.25rem; } - - /* Buttons */ - .btn-primary, .btn-secondary, .btn-ghost, .btn-seal { - display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; - border-radius: var(--radius-md); padding: 0.45rem 0.85rem; - font-size: var(--font-size-sm, 0.75rem); font-weight: var(--font-weight-semibold); - cursor: pointer; white-space: nowrap; transition: background 140ms ease, border-color 140ms ease, box-shadow 140ms ease; - } - .btn-primary { border: 1px solid var(--color-btn-primary-border); background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } - .btn-primary:hover:not(:disabled) { background: var(--color-btn-primary-bg-hover); box-shadow: var(--shadow-sm); } - .btn-secondary { border: 1px solid var(--color-btn-secondary-border); background: var(--color-btn-secondary-bg); color: var(--color-btn-secondary-text); } - .btn-secondary:hover:not(:disabled) { background: var(--color-btn-secondary-hover-bg); border-color: var(--color-btn-secondary-hover-border); } - .btn-ghost { border: 1px solid var(--color-border-primary); background: transparent; color: var(--color-text-secondary); } - .btn-ghost:hover:not(:disabled) { background: var(--color-surface-secondary); color: var(--color-text-primary); } - .btn-seal { border: 1px solid var(--color-btn-primary-border); background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); padding: 0.45rem 1.1rem; } - .btn-seal:hover:not(:disabled) { background: var(--color-btn-primary-bg-hover); box-shadow: var(--shadow-sm); } - .btn-seal__spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin 0.7s linear infinite; } - @keyframes spin { to { transform: rotate(360deg); } } - .btn-primary:disabled, .btn-secondary:disabled, .btn-ghost:disabled, .btn-seal:disabled { opacity: 0.45; cursor: not-allowed; } - .btn-sm { padding: 0.25rem 0.5rem; font-size: var(--font-size-xs, 0.6875rem); } - .btn-remove { - display: inline-flex; align-items: center; justify-content: center; - width: 26px; height: 26px; border: none; border-radius: var(--radius-sm); - background: transparent; color: var(--color-text-muted); cursor: pointer; transition: color 140ms ease, background 140ms ease; - } - .btn-remove:hover { color: var(--color-status-error-text); background: var(--color-status-error-bg); } - .btn-remove--sm { width: 22px; height: 22px; align-self: center; } - - /* Responsive */ - @media (max-width: 720px) { - .form-row-2 { grid-template-columns: 1fr; } - .review-cards { grid-template-columns: 1fr; } - .stepper__label { display: none; } - .wizard-header { flex-direction: column; gap: 0.5rem; } - .stage-row { grid-template-columns: 24px 1fr auto; } - .stage-row__arrow { display: none; } - } - `], -}) -export class CreateDeploymentComponent { - private readonly router = inject(Router); - private readonly route = inject(ActivatedRoute); - private readonly bundleApi = inject(BundleOrganizerApi); - private readonly deploymentApi = inject(DEPLOYMENT_API); - readonly store = inject(ReleaseManagementStore); - readonly platformCtx = inject(PlatformContextStore); - - readonly step = signal(1); - readonly submitError = signal(null); - readonly submitting = signal(false); - createConfirmed = false; - - readonly fmtDigest = formatDigest; - - readonly linkedRelease = signal(null); - readonly availableVersions = signal([]); - readonly availableHotfixes = signal([]); - readonly packageLoadError = signal(null); - - constructor() { - this.platformCtx.initialize(); - - this.route.queryParamMap.subscribe(params => { - const releaseId = params.get('releaseId'); - if (releaseId) { - this.store.selectRelease(releaseId); - const existing = this.store.selectedRelease(); - if (existing) { - this.setLinkedRelease(existing); - } else { - // Wait for the store to load it - const check = setInterval(() => { - const loaded = this.store.selectedRelease(); - if (loaded && loaded.id === releaseId) { - this.setLinkedRelease(loaded); - clearInterval(check); - } - }, 200); - // Give up after 5s - setTimeout(() => clearInterval(check), 5000); - } - } - }); - } - - readonly steps = [ - { n: 1, label: 'Package' }, - { n: 2, label: 'Target' }, - { n: 3, label: 'Strategy' }, - { n: 4, label: 'Review & Create' }, - ]; - - // ─── Step 1: Package selection ─── - readonly packageType = signal<'version' | 'hotfix'>('version'); - readonly selectedVersion = signal(null); - readonly selectedHotfix = signal(null); - readonly showInlineVersion = signal(false); - readonly showInlineHotfix = signal(false); - - versionSearch = ''; - hotfixSearch = ''; - - // Inline version creation - readonly inlineVersion = { name: '', version: '' }; - inlineImageSearch = ''; - readonly inlineSelectedImage = signal(null); - readonly inlineSelectedDigest = signal(''); - inlineComponents: Array<{ name: string; digest: string; tag: string }> = []; - - // Inline hotfix creation - inlineHotfixImageSearch = ''; - readonly inlineHotfixImage = signal(null); - readonly inlineHotfixDigest = signal(''); - - getFilteredVersions(): VersionOption[] { - const q = this.versionSearch.trim().toLowerCase(); - const versions = this.availableVersions(); - if (!q) return versions; - return versions.filter( - (v) => v.name.toLowerCase().includes(q) || v.version.toLowerCase().includes(q), - ); - } - - getFilteredHotfixes(): HotfixOption[] { - const q = this.hotfixSearch.trim().toLowerCase(); - const hotfixes = this.availableHotfixes(); - if (!q) return hotfixes; - return hotfixes.filter( - (h) => h.name.toLowerCase().includes(q) || h.tag.toLowerCase().includes(q), - ); - } - - // ─── Step 2: Targets ─── - readonly targetRegions = signal([]); - readonly targetEnvironments = signal([]); - - promotionStages: PromotionStage[] = [ - { name: 'Development', environmentId: '' }, - { name: 'Staging', environmentId: '' }, - { name: 'Production', environmentId: '' }, - ]; - - getFilteredEnvironments() { - const allEnvs = this.platformCtx.environments(); - const selectedRegions = this.targetRegions(); - if (selectedRegions.length === 0) return allEnvs; - const regionSet = new Set(selectedRegions.map((r: string) => r.toLowerCase())); - return allEnvs.filter((env: any) => regionSet.has(env.regionId?.toLowerCase())); - } - - getAllEnvironments() { - return this.platformCtx.environments(); - } - - getTargetRegionNames() { - const regions = this.platformCtx.regions(); - return this.targetRegions().map((id: string) => { - const region = regions.find((r: any) => r.regionId === id); - return region?.displayName ?? id; - }); - } - - getTargetEnvironmentNames() { - const envs = this.platformCtx.environments(); - return this.targetEnvironments().map((id: string) => { - const env = envs.find((e: any) => e.environmentId === id); - return env?.displayName ?? id; - }); - } - - // ─── Step 3: Strategy ─── - deploymentStrategy: DeploymentStrategy = 'rolling'; - - readonly strategyConfig = { - rolling: { - batchSize: 1, - batchSizeType: 'count' as 'count' | 'percentage', - batchDelay: 0, - stabilizationTime: 30, - maxFailedBatches: 0, - healthCheckType: 'http' as 'http' | 'tcp' | 'command', - }, - canary: { - stages: [ - { trafficPercent: 10, durationMinutes: 5, healthThreshold: 99 }, - { trafficPercent: 50, durationMinutes: 10, healthThreshold: 99 }, - ] as Array<{ trafficPercent: number; durationMinutes: number; healthThreshold: number }>, - errorRateThreshold: 1, - latencyThreshold: 500, - }, - blueGreen: { - switchoverMode: 'instant' as 'instant' | 'gradual', - warmupPeriod: 60, - blueKeepalive: 30, - validationCommand: '', - }, - allAtOnce: { - maxConcurrency: 0, - failureBehavior: 'rollback' as 'rollback' | 'continue' | 'pause', - healthCheckTimeout: 120, - }, - }; - - getStrategyLabel(): string { return getStrategyLabel(this.deploymentStrategy); } - - // ─── Step navigation ─── - - setPackageType(type: 'version' | 'hotfix'): void { - this.packageType.set(type); - if (type === 'hotfix') { - this.deploymentStrategy = 'rolling'; - this.strategyConfig.rolling.batchSize = 1; - } - } - - canContinue(): boolean { - if (this.step() === 1) { - if (this.packageType() === 'version') return !!this.selectedVersion(); - return !!this.selectedHotfix(); - } - if (this.step() === 2) { - if (this.packageType() === 'version') { - return this.promotionStages.some(s => s.environmentId !== ''); - } - } - return true; - } - - canCreate(): boolean { - const hasPackage = this.packageType() === 'version' - ? !!this.selectedVersion() - : !!this.selectedHotfix(); - return hasPackage && this.createConfirmed && !this.submitting(); - } - - nextStep(): void { - if (!this.canContinue() || this.step() >= 4) return; - this.step.update((v) => v + 1); - } - - prevStep(): void { - if (this.step() > 1) this.step.update((v) => v - 1); - } - - // ─── Package selection ─── - - selectVersion(v: VersionOption): void { - this.selectedVersion.set(v); - this.showInlineVersion.set(false); - this.versionSearch = ''; - } - - clearVersion(): void { - this.selectedVersion.set(null); - this.versionSearch = ''; - } - - selectHotfix(h: HotfixOption): void { - this.selectedHotfix.set(h); - this.showInlineHotfix.set(false); - this.hotfixSearch = ''; - } - - clearHotfix(): void { - this.selectedHotfix.set(null); - this.hotfixSearch = ''; - } - - // ─── Inline version creation ─── - - onInlineImageSearch(query: string): void { - this.store.searchImages(query); - } - - selectInlineImage(img: RegistryImage): void { - this.inlineSelectedImage.set(img); - this.inlineSelectedDigest.set(''); - this.store.clearSearchResults(); - this.inlineImageSearch = ''; - } - - pickInlineDigest(d: { tag: string; digest: string }): void { - this.inlineSelectedDigest.set(d.digest); - } - - addInlineComponent(): void { - const img = this.inlineSelectedImage(); - const digest = this.inlineSelectedDigest(); - if (!img || !digest) return; - const digestEntry = img.digests.find((d) => d.digest === digest); - this.inlineComponents.push({ name: img.name, digest, tag: digestEntry?.tag ?? '' }); - this.inlineSelectedImage.set(null); - this.inlineSelectedDigest.set(''); - } - - sealInlineVersion(): void { - if (!this.inlineVersion.name.trim() || !this.inlineVersion.version.trim() || this.inlineComponents.length === 0) return; - - const name = this.inlineVersion.name.trim(); - const version = this.inlineVersion.version.trim(); - const slug = this.toSlug(name); - const description = `Version ${version}`; - - const publishRequest = { - changelog: description, - components: this.inlineComponents.map((c, i) => ({ - componentName: c.name, - componentVersionId: `${c.name}@${c.tag || c.digest.slice(7, 19)}`, - imageDigest: c.digest, - deployOrder: (i + 1) * 10, - metadataJson: JSON.stringify({ tag: c.tag || null }), - })), - }; - - this.submitting.set(true); - this.submitError.set(null); - - this.createOrReuseBundle(slug, name, description).pipe( - switchMap(bundle => this.bundleApi.publishBundleVersion(bundle.id, publishRequest)), - finalize(() => this.submitting.set(false)), - ).subscribe({ - next: (result) => { - const versionOption: VersionOption = { - id: result.id, - name, - version, - componentCount: this.inlineComponents.length, - sealedAt: result.publishedAt ?? new Date().toISOString(), - }; - this.availableVersions.update((items) => [versionOption, ...items.filter((item) => item.id !== versionOption.id)]); - this.selectedVersion.set(versionOption); - this.showInlineVersion.set(false); - this.inlineVersion.name = ''; - this.inlineVersion.version = ''; - this.inlineComponents = []; - }, - error: (error) => { - this.submitError.set(this.mapCreateError(error)); - }, - }); - } - - // ─── Inline hotfix creation ─── - - onInlineHotfixImageSearch(query: string): void { - this.store.searchImages(query); - } - - selectInlineHotfixImage(img: RegistryImage): void { - this.inlineHotfixImage.set(img); - this.inlineHotfixDigest.set(''); - this.store.clearSearchResults(); - this.inlineHotfixImageSearch = ''; - } - - pickInlineHotfixDigest(d: { tag: string; digest: string }): void { - this.inlineHotfixDigest.set(d.digest); - } - - sealInlineHotfix(): void { - const img = this.inlineHotfixImage(); - const digest = this.inlineHotfixDigest(); - if (!img || !digest) return; - - const digestEntry = img.digests.find((d) => d.digest === digest); - const tag = digestEntry?.tag ?? ''; - const now = new Date(); - const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; - const hotfixName = `${img.name}-hotfix`; - const hotfixTag = tag ? `${tag}-hf.${ts}` : `hf.${ts}`; - const slug = this.toSlug(hotfixName); - const description = `Hotfix ${hotfixTag} from ${img.repository}`; - - const publishRequest = { - changelog: description, - components: [{ - componentName: img.name, - componentVersionId: `${img.name}@${hotfixTag}`, - imageDigest: digest, - deployOrder: 10, - metadataJson: JSON.stringify({ imageRef: img.repository, tag: tag || null, hotfix: true }), - }], - }; - - this.submitting.set(true); - this.submitError.set(null); - - this.createOrReuseBundle(slug, hotfixName, description).pipe( - switchMap(bundle => this.bundleApi.publishBundleVersion(bundle.id, publishRequest).pipe( - switchMap(version => this.bundleApi.materializeBundleVersion(bundle.id, version.id, { - reason: `Inline hotfix creation: ${hotfixTag}`, - }).pipe(map(() => version))), - )), - finalize(() => this.submitting.set(false)), - ).subscribe({ - next: (result) => { - const hotfixOption: HotfixOption = { - id: result.id, - name: hotfixName, - image: img.repository, - tag: hotfixTag, - sealedAt: result.publishedAt ?? now.toISOString(), - }; - this.availableHotfixes.update((items) => [hotfixOption, ...items.filter((item) => item.id !== hotfixOption.id)]); - this.selectedHotfix.set(hotfixOption); - this.showInlineHotfix.set(false); - this.inlineHotfixImage.set(null); - this.inlineHotfixDigest.set(''); - }, - error: (error) => { - this.submitError.set(this.mapCreateError(error)); - }, - }); - } - - // ─── Target helpers ─── - - isRegionSelected(regionId: string): boolean { - return this.targetRegions().includes(regionId); - } - - isEnvironmentSelected(envId: string): boolean { - return this.targetEnvironments().includes(envId); - } - - toggleRegion(regionId: string): void { - const current = this.targetRegions(); - if (this.packageType() === 'hotfix') { - this.targetRegions.set(current.includes(regionId) ? [] : [regionId]); - this.targetEnvironments.set([]); - return; - } - if (current.includes(regionId)) { - this.targetRegions.set(current.filter((id) => id !== regionId)); - const remainingRegions = new Set(this.targetRegions().map((r) => r.toLowerCase())); - if (remainingRegions.size > 0) { - const allEnvs = this.platformCtx.environments(); - this.targetEnvironments.update((envIds) => - envIds.filter((envId) => { - const env = allEnvs.find((e) => e.environmentId === envId); - return env ? remainingRegions.has(env.regionId.toLowerCase()) : false; - }), - ); - } - } else { - this.targetRegions.set([...current, regionId]); - } - } - - toggleEnvironment(envId: string): void { - const current = this.targetEnvironments(); - if (this.packageType() === 'hotfix') { - this.targetEnvironments.set(current.includes(envId) ? [] : [envId]); - return; - } - if (current.includes(envId)) { - this.targetEnvironments.set(current.filter((id) => id !== envId)); - } else { - this.targetEnvironments.set([...current, envId]); - } - } - - regionDisplayName(regionId: string): string { - const region = this.platformCtx.regions().find((r) => r.regionId === regionId); - return region?.displayName ?? regionId; - } - - environmentDisplayName(envId: string): string { - const env = this.platformCtx.environments().find((e) => e.environmentId === envId); - return env?.displayName ?? envId; - } - - // ─── Promotion stages ─── - - addStage(): void { - this.promotionStages.push({ name: '', environmentId: '' }); - } - - removeStage(index: number): void { - if (this.promotionStages.length > 1) { - this.promotionStages.splice(index, 1); - } - } - - promotionStageNames(): string { - return this.promotionStages - .filter((s) => s.name.trim()) - .map((s) => s.name) - .join(' -> ') || 'none configured'; - } - - // ─── Strategy helpers ─── - - addCanaryStage(): void { - this.strategyConfig.canary.stages.push({ trafficPercent: 100, durationMinutes: 5, healthThreshold: 99 }); - } - - removeCanaryStage(index: number): void { - if (this.strategyConfig.canary.stages.length > 1) { - this.strategyConfig.canary.stages.splice(index, 1); - } - } - - // ─── Create deployment ─── - - createDeployment(): void { - if (!this.canCreate()) return; - - this.submitting.set(true); - this.submitError.set(null); - - const pkg = this.packageType() === 'version' ? this.selectedVersion() : this.selectedHotfix(); - const release = this.linkedRelease(); - const environmentId = this.resolveTargetEnvironmentId(); - if (!pkg || !release || !environmentId) { - this.submitError.set('Select a package and target environment before creating the deployment.'); - this.submitting.set(false); - return; - } - - const request: CreateDeploymentRequest = { - releaseId: release.id, - environmentId, - environmentName: this.environmentDisplayName(environmentId), - strategy: this.deploymentStrategy, - strategyConfig: this.getActiveStrategyConfig(), - packageType: this.packageType(), - packageRefId: pkg.id, - packageRefName: pkg.name, - promotionStages: this.promotionStages.filter((stage) => stage.environmentId.trim().length > 0), - }; - - this.deploymentApi.createDeployment(request).pipe( - finalize(() => this.submitting.set(false)), - ).subscribe({ - next: (deployment) => { - void this.router.navigate(['/releases/deployments', deployment.id], { queryParamsHandling: 'merge' }); - }, - error: (error) => { - this.submitError.set(this.mapCreateError(error)); - }, - }); - } - - private getActiveStrategyConfig(): unknown { - switch (this.deploymentStrategy) { - case 'rolling': return this.strategyConfig.rolling; - case 'canary': return this.strategyConfig.canary; - case 'blue_green': return this.strategyConfig.blueGreen; - case 'all_at_once': return this.strategyConfig.allAtOnce; - default: return {}; - } - } - - // ─── Private helpers ─── - - private setLinkedRelease(release: ManagedRelease): void { - this.linkedRelease.set(release); - this.packageType.set(release.hotfixLane ? 'hotfix' : 'version'); - this.loadPackageOptions(release); - } - - private loadPackageOptions(release: ManagedRelease): void { - this.packageLoadError.set(null); - this.availableVersions.set([]); - this.availableHotfixes.set([]); - this.selectedVersion.set(null); - this.selectedHotfix.set(null); - - this.bundleApi.listBundleVersions(release.id, 50, 0).pipe( - switchMap((versions) => { - if (versions.length === 0) { - return of([] as Array<{ detail: any }>); - } - return forkJoin( - versions.map((version) => - this.bundleApi.getBundleVersion(release.id, version.id).pipe( - map((detail) => ({ detail })), - ), - ), - ); - }), - ).subscribe({ - next: (items) => { - const versions = items.map(({ detail }) => this.toVersionOption(release, detail)); - const hotfixes = items - .map(({ detail }) => this.toHotfixOption(release, detail)) - .filter((item): item is HotfixOption => item !== null); - this.availableVersions.set(versions); - this.availableHotfixes.set(hotfixes); - }, - error: (error) => { - this.packageLoadError.set(this.mapCreateError(error)); - }, - }); - } - - private toVersionOption(release: ManagedRelease, detail: any): VersionOption { - return { - id: detail.id, - name: release.name, - version: `Version ${detail.versionNumber}`, - componentCount: detail.components?.length ?? 0, - sealedAt: detail.publishedAt ?? detail.createdAt, - }; - } - - private toHotfixOption(release: ManagedRelease, detail: any): HotfixOption | null { - const firstComponent = detail.components?.[0]; - const metadata = this.parseMetadata(firstComponent?.metadataJson); - const tag = typeof metadata['tag'] === 'string' ? metadata['tag'] : ''; - const isHotfix = release.hotfixLane || tag.includes('hf') || metadata['hotfix'] === true; - if (!isHotfix || !firstComponent) { - return null; - } - - return { - id: detail.id, - name: `${release.name}-hotfix`, - image: typeof metadata['imageRef'] === 'string' ? metadata['imageRef'] : firstComponent.componentName, - tag: tag || `hf-${detail.versionNumber}`, - sealedAt: detail.publishedAt ?? detail.createdAt, - }; - } - - private parseMetadata(raw: string | null | undefined): Record { - if (!raw) return {}; - try { - const parsed = JSON.parse(raw); - return parsed && typeof parsed === 'object' ? parsed as Record : {}; - } catch { - return {}; - } - } - - private resolveTargetEnvironmentId(): string | null { - if (this.packageType() === 'hotfix') { - return this.targetEnvironments()[0] ?? null; - } - return this.promotionStages.find((stage) => stage.environmentId.trim().length > 0)?.environmentId ?? null; - } - - private createOrReuseBundle(slug: string, name: string, description: string) { - return this.bundleApi.createBundle({ slug, name, description }).pipe( - catchError(error => { - if (this.statusCodeOf(error) !== 409) { - return throwError(() => error); - } - return this.bundleApi.listBundles(200, 0).pipe( - map(bundles => { - const existing = bundles.find(b => b.slug === slug); - if (!existing) throw error; - return existing; - }), - ); - }), - ); - } - - private toSlug(value: string): string { - const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); - return normalized || `deployment-${Date.now()}`; - } - - private statusCodeOf(error: unknown): number | null { - if (!error || typeof error !== 'object' || !('status' in error)) return null; - const status = (error as { status?: unknown }).status; - return typeof status === 'number' ? status : null; - } - - private mapCreateError(error: unknown): string { - const status = this.statusCodeOf(error); - if (status === 403) return 'Deployment creation requires orch:operate scope. Current session is not authorized.'; - if (status === 409) return 'A deployment with this identity already exists or is already in progress.'; - if (status === 503) return 'Release control backend is unavailable. The deployment was not created.'; - return 'Failed to create deployment.'; - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/releases/state/release-detail.store.ts b/src/Web/StellaOps.Web/src/app/features/releases/state/release-detail.store.ts deleted file mode 100644 index 763494933..000000000 --- a/src/Web/StellaOps.Web/src/app/features/releases/state/release-detail.store.ts +++ /dev/null @@ -1,391 +0,0 @@ -/** - * Release Detail Store - * Sprint: SPRINT_20260118_004_FE_releases_feature (REL-011) - * - * Signal-based state management for the release detail page. - * Handles release data, tab content, and release actions. - */ - -import { Injectable, signal, computed, inject } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { catchError, of, tap } from 'rxjs'; - -// === Interfaces === - -export interface Release { - id: string; - version: string; - bundleDigest: string; - createdAt: string; - source: string; - sourceRef: string; - status: 'draft' | 'active' | 'archived'; - componentCount: number; - tags: string[]; -} - -export interface DeploymentMapEntry { - environment: string; - releaseVersion: string | null; - releaseDigest: string | null; - status: 'deployed' | 'pending' | 'failed' | 'none'; - deployedAt: string | null; - isCurrent: boolean; -} - -export interface DeploymentMap { - entries: DeploymentMapEntry[]; -} - -export interface GateSummary { - policyRef: string; - policyVersion: string; - snapshotRef: string; - overallStatus: 'PASS' | 'WARN' | 'BLOCK'; - gates: GateResult[]; -} - -export interface GateResult { - gateId: string; - name: string; - status: 'PASS' | 'WARN' | 'BLOCK' | 'SKIP'; - reason?: string; -} - -export interface SecurityImpact { - newCves: number; - newReachableCves: number; - fixedCves: number; - totalCves: number; - vexApplied: number; - riskDelta: 'increased' | 'decreased' | 'unchanged'; -} - -export interface EvidencePacket { - evidenceId: string; - type: 'promotion' | 'deployment' | 'release' | 'scan' | 'audit'; - subject: string; - signed: boolean; - verified: boolean; - snapshotDate: string; - bundleDigest: string; -} - -export interface ReleaseComponent { - name: string; - version: string; - digest: string; - type: 'library' | 'framework' | 'runtime' | 'tool'; - origin: string; - license: string; - cveCount: number; -} - -export interface Promotion { - id: string; - fromEnv: string; - toEnv: string; - status: 'pending' | 'approved' | 'rejected'; - requestedBy: string; - requestedAt: string; - decidedBy?: string; - decidedAt?: string; -} - -export interface Deployment { - id: string; - environment: string; - status: 'running' | 'completed' | 'failed'; - startedAt: string; - completedAt?: string; - duration?: string; - targets: number; -} - -// === Store === - -@Injectable({ providedIn: 'root' }) -export class ReleaseDetailStore { - private http = inject(HttpClient); - private apiBase = '/api/releases'; - - // === Core State === - readonly release = signal(null); - readonly deploymentMap = signal(null); - readonly gateSummary = signal(null); - readonly securityImpact = signal(null); - readonly latestEvidence = signal(null); - - // === Tab State (lazy loaded) === - readonly activeTab = signal('overview'); - readonly components = signal([]); - readonly promotions = signal([]); - readonly deployments = signal([]); - readonly evidence = signal([]); - - // === Loading & Error State === - readonly loading = signal(false); - readonly error = signal(null); - readonly tabLoading = signal>({}); - - // === Computed Properties === - readonly releaseId = computed(() => this.release()?.id ?? null); - readonly releaseVersion = computed(() => this.release()?.version ?? null); - readonly bundleDigest = computed(() => this.release()?.bundleDigest ?? null); - - readonly hasBlockingGates = computed(() => { - const summary = this.gateSummary(); - return summary?.gates.some(g => g.status === 'BLOCK') ?? false; - }); - - /** Whether the Promote button should be visible (has a promotion path at all). */ - readonly showPromote = computed(() => { - return this.release() !== null && this.nextPromotionTarget() !== null; - }); - - /** Whether the Promote action is enabled (deployed on current env, no blocking gates). */ - readonly canPromote = computed(() => { - if (!this.release()) return false; - if (this.hasBlockingGates()) return false; - if (!this.nextPromotionTarget()) return false; - // Must be deployed on current environment before promoting further - const r = this.release()!; - if (r.status === 'draft') return false; - return true; - }); - - /** Reason text when Promote is disabled but visible. */ - readonly promoteDisabledReason = computed(() => { - if (!this.showPromote()) return null; - if (this.canPromote()) return null; - if (this.hasBlockingGates()) return 'Resolve blocking gates before promoting'; - const r = this.release(); - if (r?.status === 'draft') return 'Deploy to the current environment first'; - return 'Not eligible for promotion'; - }); - - readonly currentEnvironments = computed(() => { - const map = this.deploymentMap(); - if (!map) return []; - return map.entries - .filter(e => e.isCurrent) - .map(e => e.environment); - }); - - readonly nextPromotionTarget = computed(() => { - const map = this.deploymentMap(); - if (!map) return null; - const current = map.entries.find(e => e.isCurrent); - if (!current) return map.entries[0]?.environment ?? null; - const currentIdx = map.entries.findIndex(e => e.environment === current.environment); - return map.entries[currentIdx + 1]?.environment ?? null; - }); - - // === Actions === - - /** - * Load release detail data - */ - load(releaseId: string): void { - this.loading.set(true); - this.error.set(null); - - // In a real app, this would be an HTTP call - // For now, we simulate with mock data - setTimeout(() => { - this.release.set({ - id: releaseId, - version: 'v1.2.5', - bundleDigest: 'sha256:7aa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9', - createdAt: '2026-01-15T10:30:00Z', - source: 'CI', - sourceRef: '#882', - status: 'active', - componentCount: 12, - tags: ['latest', 'staging'], - }); - - this.deploymentMap.set({ - entries: [ - { environment: 'Dev', releaseVersion: 'v1.3.0', releaseDigest: 'sha256:abc...', status: 'deployed', deployedAt: '2026-01-18T08:00:00Z', isCurrent: false }, - { environment: 'QA', releaseVersion: 'v1.2.5', releaseDigest: 'sha256:7aa...', status: 'deployed', deployedAt: '2026-01-17T14:00:00Z', isCurrent: true }, - { environment: 'Staging', releaseVersion: null, releaseDigest: null, status: 'pending', deployedAt: null, isCurrent: false }, - { environment: 'Prod', releaseVersion: 'v1.2.3', releaseDigest: 'sha256:def...', status: 'deployed', deployedAt: '2026-01-10T09:00:00Z', isCurrent: false }, - ], - }); - - this.gateSummary.set({ - policyRef: 'stg-baseline', - policyVersion: 'v3.1', - snapshotRef: 'snap_20260115', - overallStatus: 'WARN', - gates: [ - { gateId: 'sbom', name: 'SBOM Signed', status: 'PASS' }, - { gateId: 'provenance', name: 'Provenance', status: 'PASS' }, - { gateId: 'reachability', name: 'Reachability', status: 'WARN', reason: '2 reachable CVEs' }, - { gateId: 'vex', name: 'VEX Consensus', status: 'PASS' }, - ], - }); - - this.securityImpact.set({ - newCves: 2, - newReachableCves: 1, - fixedCves: 5, - totalCves: 8, - vexApplied: 3, - riskDelta: 'decreased', - }); - - this.latestEvidence.set({ - evidenceId: 'EVD-2026-045', - type: 'release', - subject: 'v1.2.5', - signed: true, - verified: true, - snapshotDate: '2026-01-15T10:35:00Z', - bundleDigest: 'sha256:evd1234...', - }); - - this.loading.set(false); - }, 300); - } - - /** - * Load tab-specific data lazily - */ - loadTab(tab: string): void { - if (this.activeTab() === tab) return; - this.activeTab.set(tab); - - // Check if data already loaded - switch (tab) { - case 'components': - if (this.components().length > 0) return; - break; - case 'promotions': - if (this.promotions().length > 0) return; - break; - case 'deployments': - if (this.deployments().length > 0) return; - break; - case 'evidence': - if (this.evidence().length > 0) return; - break; - default: - return; - } - - // Load data for tab - this.tabLoading.update(state => ({ ...state, [tab]: true })); - - setTimeout(() => { - switch (tab) { - case 'components': - this.components.set([ - { name: 'express', version: '4.18.2', digest: 'sha256:abc...', type: 'framework', origin: 'npm', license: 'MIT', cveCount: 1 }, - { name: 'lodash', version: '4.17.21', digest: 'sha256:def...', type: 'library', origin: 'npm', license: 'MIT', cveCount: 0 }, - { name: 'log4j-core', version: '2.14.1', digest: 'sha256:ghi...', type: 'library', origin: 'maven', license: 'Apache-2.0', cveCount: 2 }, - { name: 'spring-boot', version: '2.7.5', digest: 'sha256:jkl...', type: 'framework', origin: 'maven', license: 'Apache-2.0', cveCount: 1 }, - { name: 'jackson-databind', version: '2.13.0', digest: 'sha256:mno...', type: 'library', origin: 'maven', license: 'Apache-2.0', cveCount: 1 }, - ]); - break; - - case 'promotions': - this.promotions.set([ - { id: 'PRM-001', fromEnv: 'Dev', toEnv: 'QA', status: 'approved', requestedBy: 'ci-bot', requestedAt: '2026-01-16T09:00:00Z', decidedBy: 'user1', decidedAt: '2026-01-16T10:00:00Z' }, - { id: 'PRM-002', fromEnv: 'QA', toEnv: 'Staging', status: 'pending', requestedBy: 'user2', requestedAt: '2026-01-17T15:00:00Z' }, - ]); - break; - - case 'deployments': - this.deployments.set([ - { id: 'DEP-001', environment: 'Dev', status: 'completed', startedAt: '2026-01-15T11:00:00Z', completedAt: '2026-01-15T11:05:00Z', duration: '5m 12s', targets: 3 }, - { id: 'DEP-002', environment: 'QA', status: 'completed', startedAt: '2026-01-17T14:00:00Z', completedAt: '2026-01-17T14:03:00Z', duration: '3m 45s', targets: 2 }, - ]); - break; - - case 'evidence': - this.evidence.set([ - { evidenceId: 'EVD-2026-045', type: 'release', subject: 'v1.2.5', signed: true, verified: true, snapshotDate: '2026-01-15T10:35:00Z', bundleDigest: 'sha256:evd1...' }, - { evidenceId: 'EVD-2026-044', type: 'promotion', subject: 'v1.2.5 Dev->QA', signed: true, verified: true, snapshotDate: '2026-01-16T10:00:00Z', bundleDigest: 'sha256:evd2...' }, - { evidenceId: 'EVD-2026-043', type: 'deployment', subject: 'DEP-002', signed: true, verified: true, snapshotDate: '2026-01-17T14:03:00Z', bundleDigest: 'sha256:evd3...' }, - ]); - break; - } - - this.tabLoading.update(state => ({ ...state, [tab]: false })); - }, 200); - } - - /** - * Request promotion to target environment - */ - requestPromotion(targetEnv: string): void { - const release = this.release(); - if (!release) return; - - console.log(`Requesting promotion of ${release.version} to ${targetEnv}`); - - // In real app, would make API call - // POST /api/releases/{releaseId}/promotions - // Body: { targetEnvironment: targetEnv } - - // Optimistically add pending promotion - const currentEnvs = this.currentEnvironments(); - const fromEnv = currentEnvs[currentEnvs.length - 1] || 'Dev'; - - this.promotions.update(list => [ - { - id: `PRM-${Date.now()}`, - fromEnv, - toEnv: targetEnv, - status: 'pending', - requestedBy: 'Current User', - requestedAt: new Date().toISOString(), - }, - ...list, - ]); - } - - /** - * Request rollback of release - */ - rollback(): void { - const release = this.release(); - if (!release) return; - - console.log(`Requesting rollback of ${release.version}`); - - // In real app, would make API call - // POST /api/releases/{releaseId}/rollback - } - - /** - * Refresh release data - */ - refresh(): void { - const release = this.release(); - if (release) { - this.load(release.id); - } - } - - /** - * Reset store state - */ - reset(): void { - this.release.set(null); - this.deploymentMap.set(null); - this.gateSummary.set(null); - this.securityImpact.set(null); - this.latestEvidence.set(null); - this.activeTab.set('overview'); - this.components.set([]); - this.promotions.set([]); - this.deployments.set([]); - this.evidence.set([]); - this.loading.set(false); - this.error.set(null); - this.tabLoading.set({}); - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.ts index 9313ac2c6..2c5e4883c 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.ts @@ -248,6 +248,7 @@ import { DoctorRecheckService } from '../../doctor/services/doctor-recheck.servi = completed ? 0.5 : 0)) / total) * 100); }); + readonly retainedSecretKeysForCurrentStep = computed(() => { + const session = this.state.session(); + const currentStepId = this.state.currentStepId(); + if (!session || !currentStepId) { + return [] as string[]; + } + + return (session.secretDrafts ?? []) + .filter(secret => !secret.stepId || secret.stepId === currentStepId) + .map(secret => secret.key); + }); + readonly isLastStep = computed(() => { const index = this.state.currentStepIndex(); const total = this.state.orderedSteps().length; @@ -1189,7 +1202,11 @@ export class SetupWizardComponent implements OnInit, OnDestroy { return; } - const merged = mergeSetupStepLocalDefaults(step.id, this.state.configValues()); + const merged = mergeSetupStepLocalDefaults( + step.id, + this.state.configValues(), + this.retainedSecretKeysForCurrentStep(), + ); this.state.setConfigValues(merged); } diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.component.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.component.ts index 0a7d3adf4..463cc86db 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.component.ts @@ -86,9 +86,20 @@ export const SETUP_STEP_LOCAL_DEFAULTS: Record> = }, }; +function isSensitiveConfigKey(key: string): boolean { + const normalized = key.trim().toLowerCase(); + return normalized.includes('password') + || normalized.includes('secret') + || normalized.includes('token') + || normalized.includes('privatekey') + || normalized.endsWith('.pin') + || normalized.endsWith('.connectionstring'); +} + export function mergeSetupStepLocalDefaults( stepId: SetupStepId, configValues: Record, + retainedSecretKeys: readonly string[] = [], ): Record { const defaults = SETUP_STEP_LOCAL_DEFAULTS[stepId]; if (!defaults) { @@ -96,8 +107,17 @@ export function mergeSetupStepLocalDefaults( } const merged = { ...configValues }; + const retainedSecrets = new Set(retainedSecretKeys); for (const [key, value] of Object.entries(defaults)) { const current = merged[key]; + if ( + (typeof current !== 'string' || current.trim().length === 0) + && isSensitiveConfigKey(key) + && retainedSecrets.has(key) + ) { + continue; + } + if (typeof current !== 'string' || current.trim().length === 0) { merged[key] = value; } @@ -298,11 +318,15 @@ export function mergeSetupStepLocalDefaults( - Must meet password policy requirements. + @if (hasRetainedSecret('users.superuser.password') && !getConfigValue('users.superuser.password')) { + Password retained securely on the server. Leave the field untouched to keep it, or type a new password to replace it. + } @else { + Must meet password policy requirements. + }
@@ -449,9 +473,12 @@ export function mergeSetupStepLocalDefaults( + @if (hasRetainedSecret('database.password') && !getConfigValue('database.password')) { + Password retained securely on the server. Leave the field untouched to keep it, or type a new password to replace it. + } @@ -515,10 +542,13 @@ export function mergeSetupStepLocalDefaults( + @if (hasRetainedSecret('cache.password') && !getConfigValue('cache.password')) { + Password retained securely on the server. Leave the field untouched to keep it, or type a new password to replace it. + }
@@ -1809,6 +1839,9 @@ export class StepContentComponent { /** Configuration values */ readonly configValues = input>({}); + /** Sensitive keys retained server-side for this step */ + readonly retainedSecretKeys = input([]); + /** Validation checks for this step */ readonly validationChecks = input([]); @@ -1889,9 +1922,14 @@ export class StepContentComponent { } const config = this.configValues(); + const retainedSecrets = new Set(this.retainedSecretKeys()); const defaults = SETUP_STEP_LOCAL_DEFAULTS[step.id]; if (defaults) { for (const [key, value] of Object.entries(defaults)) { + if (!config[key] && isSensitiveConfigKey(key) && retainedSecrets.has(key)) { + continue; + } + if (!config[key]) { this.configChange.emit({ key, value }); } @@ -1982,6 +2020,19 @@ export class StepContentComponent { return this.configValues()[key] ?? ''; } + getSecretInputValue(key: string, fallback = ''): string { + const current = this.getConfigValue(key); + if (current) { + return current; + } + + return this.hasRetainedSecret(key) ? '' : fallback; + } + + hasRetainedSecret(key: string): boolean { + return this.retainedSecretKeys().includes(key); + } + /** * Get provider fields */ diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.defaults.spec.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.defaults.spec.ts index b354efe6e..0d37b0041 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.defaults.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.defaults.spec.ts @@ -33,4 +33,17 @@ describe('StepContentComponent local defaults', () => { 'users.superuser.password': 'Admin@Stella1', })); }); + + it('does not inject sensitive defaults when the backend retained a secret for the step', () => { + expect(mergeSetupStepLocalDefaults('admin', { + 'users.superuser.username': 'admin', + }, ['users.superuser.password'])).toEqual(expect.objectContaining({ + 'authority.provider': 'standard', + 'users.superuser.username': 'admin', + 'users.superuser.email': 'admin@stella-ops.local', + })); + expect(mergeSetupStepLocalDefaults('admin', { + 'users.superuser.username': 'admin', + }, ['users.superuser.password'])['users.superuser.password']).toBeUndefined(); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/models/setup-wizard.models.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/models/setup-wizard.models.ts index c42b6af38..76011769f 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/models/setup-wizard.models.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/models/setup-wizard.models.ts @@ -110,6 +110,12 @@ export interface SetupSessionStepState { error?: string | null; } +export interface SetupSecretDraft { + key: string; + stepId?: SetupStepId | null; + updatedAt: string; +} + /** Wizard session state */ export interface SetupSession { sessionId: string; @@ -124,6 +130,7 @@ export interface SetupSession { steps: SetupSessionStepState[]; completedSteps?: SetupStepId[]; skippedSteps?: SetupStepId[]; + secretDrafts?: SetupSecretDraft[]; } /** Request to execute a setup step */ diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.spec.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.spec.ts index fc3e4f75e..196e707f8 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.spec.ts @@ -2,6 +2,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; import { ExecuteStepRequest, SetupStepId } from '../models/setup-wizard.models'; import { ProblemDetails, SetupApiError, SetupWizardApiService } from './setup-wizard-api.service'; @@ -29,14 +30,8 @@ describe('SetupWizardApiService', () => { httpMock.verify(); }); - it('maps createSession onto the installation-scoped backend contract', (done) => { - service.createSession().subscribe((session) => { - expect(session.sessionId).toBe('session-123'); - expect(session.scopeKey).toBe('installation'); - expect(session.currentStep).toBe('migrations'); - expect(session.completedSteps).toEqual(['database']); - done(); - }); + it('maps createSession onto the installation-scoped backend contract', async () => { + const sessionPromise = firstValueFrom(service.createSession()); const req = httpMock.expectOne(`${setupBaseUrl}/sessions`); expect(req.request.method).toBe('POST'); @@ -62,28 +57,45 @@ describe('SetupWizardApiService', () => { draftValues: { 'database.host': 'localhost', }, + secretDrafts: [ + { + key: 'users.superuser.password', + stepId: 'Admin', + updatedAtUtc: '2026-04-14T00:04:00Z', + }, + ], createdAtUtc: '2026-04-14T00:00:00Z', updatedAtUtc: '2026-04-14T00:05:00Z', }, }); + + const session = await sessionPromise; + expect(session.sessionId).toBe('session-123'); + expect(session.scopeKey).toBe('installation'); + expect(session.currentStep).toBe('migrations'); + expect(session.completedSteps).toEqual(['database']); + expect(session.secretDrafts).toEqual([ + { + key: 'users.superuser.password', + stepId: 'admin', + updatedAt: '2026-04-14T00:04:00Z', + }, + ]); }); - it('returns null when resuming a missing session', (done) => { - service.resumeSession('missing').subscribe((session) => { - expect(session).toBeNull(); - done(); - }); + it('returns null when resuming a missing session', async () => { + const sessionPromise = firstValueFrom(service.resumeSession('missing')); const req = httpMock.expectOne(`${setupBaseUrl}/sessions/missing`); req.flush(null, { status: 404, statusText: 'Not Found' }); + + await expect(sessionPromise).resolves.toBeNull(); }); - it('maps saveDraftConfig responses back into the frontend session shape', (done) => { - service.saveDraftConfig('session-123', { 'database.host': 'localhost' }).subscribe((session) => { - expect(session.configValues).toEqual({ 'database.host': 'localhost' }); - expect(session.currentStep).toBe('database'); - done(); - }); + it('maps saveDraftConfig responses back into the frontend session shape', async () => { + const sessionPromise = firstValueFrom( + service.saveDraftConfig('session-123', { 'database.host': 'localhost' }), + ); const req = httpMock.expectOne(`${setupBaseUrl}/sessions/session-123/config`); expect(req.request.method).toBe('PUT'); @@ -105,9 +117,13 @@ describe('SetupWizardApiService', () => { }, }, }); + + const session = await sessionPromise; + expect(session.configValues).toEqual({ 'database.host': 'localhost' }); + expect(session.currentStep).toBe('database'); }); - it('uses probeStep for dry-run execute requests', (done) => { + it('uses probeStep for dry-run execute requests', async () => { const request: ExecuteStepRequest = { sessionId: 'session-123', stepId: 'database', @@ -115,12 +131,7 @@ describe('SetupWizardApiService', () => { dryRun: true, }; - service.executeStep(request).subscribe((result) => { - expect(result.stepId).toBe('database'); - expect(result.status).toBe('completed'); - expect(result.message).toBe('Probe successful'); - done(); - }); + const resultPromise = firstValueFrom(service.executeStep(request)); const req = httpMock.expectOne(`${setupBaseUrl}/sessions/session-123/steps/database/probe`); expect(req.request.method).toBe('POST'); @@ -133,14 +144,15 @@ describe('SetupWizardApiService', () => { canRetry: true, }, }); + + const result = await resultPromise; + expect(result.stepId).toBe('database'); + expect(result.status).toBe('completed'); + expect(result.message).toBe('Probe successful'); }); - it('maps applyStep responses to frontend status and step ids', (done) => { - service.applyStep('session-123', 'migrations', {}).subscribe((result) => { - expect(result.stepId).toBe('migrations'); - expect(result.status).toBe('completed'); - done(); - }); + it('maps applyStep responses to frontend status and step ids', async () => { + const resultPromise = firstValueFrom(service.applyStep('session-123', 'migrations', {})); const req = httpMock.expectOne(`${setupBaseUrl}/sessions/session-123/steps/migrations/apply`); expect(req.request.method).toBe('POST'); @@ -152,18 +164,18 @@ describe('SetupWizardApiService', () => { canRetry: true, }, }); + + const result = await resultPromise; + expect(result.stepId).toBe('migrations'); + expect(result.status).toBe('completed'); }); - it('maps skipStep against the current control-plane routes', (done) => { - service.skipStep({ + it('maps skipStep against the current control-plane routes', async () => { + const resultPromise = firstValueFrom(service.skipStep({ sessionId: 'session-123', stepId: 'crypto', reason: 'Handled elsewhere', - }).subscribe((result) => { - expect(result.stepId).toBe('crypto'); - expect(result.status).toBe('skipped'); - done(); - }); + })); const req = httpMock.expectOne(`${setupBaseUrl}/sessions/session-123/steps/crypto/skip`); expect(req.request.method).toBe('POST'); @@ -176,19 +188,14 @@ describe('SetupWizardApiService', () => { canRetry: false, }, }); + + const result = await resultPromise; + expect(result.stepId).toBe('crypto'); + expect(result.status).toBe('skipped'); }); - it('maps warning validation checks into failed frontend checks', (done) => { - service.runValidationChecks('session-123', 'database').subscribe((checks) => { - expect(checks).toEqual([ - jasmine.objectContaining({ - checkId: 'check.database.connectivity', - status: 'failed', - severity: 'warning', - }), - ]); - done(); - }); + it('maps warning validation checks into failed frontend checks', async () => { + const checksPromise = firstValueFrom(service.runValidationChecks('session-123', 'database')); const req = httpMock.expectOne(`${setupBaseUrl}/sessions/session-123/steps/database/checks/run`); expect(req.request.method).toBe('POST'); @@ -204,14 +211,19 @@ describe('SetupWizardApiService', () => { }, ], }); + + const checks = await checksPromise; + expect(checks).toEqual([ + jasmine.objectContaining({ + checkId: 'check.database.connectivity', + status: 'failed', + severity: 'warning', + }), + ]); }); - it('posts testConnection to the canonical step endpoint', (done) => { - service.testConnection('cache', {}).subscribe((result) => { - expect(result.success).toBeTrue(); - expect(result.latencyMs).toBe(12); - done(); - }); + it('posts testConnection to the canonical step endpoint', async () => { + const resultPromise = firstValueFrom(service.testConnection('cache', {})); const req = httpMock.expectOne(`${setupBaseUrl}/steps/cache/test-connection`); expect(req.request.method).toBe('POST'); @@ -222,14 +234,14 @@ describe('SetupWizardApiService', () => { latencyMs: 12, }, }); + + const result = await resultPromise; + expect(result.success).toBeTrue(); + expect(result.latencyMs).toBe(12); }); - it('posts finalizeSetup to the current session finalize endpoint', (done) => { - service.finalizeSetup('session-123').subscribe((result) => { - expect(result.success).toBeTrue(); - expect(result.restartRequired).toBeFalse(); - done(); - }); + it('posts finalizeSetup to the current session finalize endpoint', async () => { + const resultPromise = firstValueFrom(service.finalizeSetup('session-123')); const req = httpMock.expectOne(`${setupBaseUrl}/sessions/session-123/finalize`); expect(req.request.method).toBe('POST'); @@ -241,6 +253,10 @@ describe('SetupWizardApiService', () => { restartRequired: false, }, }); + + const result = await resultPromise; + expect(result.success).toBeTrue(); + expect(result.restartRequired).toBeFalse(); }); it('parses problem details into a retryable setup error', () => { diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.ts index 4d60ff4d1..62fe91142 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-api.service.ts @@ -3,6 +3,7 @@ import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Observable, catchError, map, of, throwError } from 'rxjs'; import { ExecuteStepRequest, + SetupSecretDraft, SkipStepRequest, SetupSession, SetupSessionStepState, @@ -61,6 +62,11 @@ interface BackendSession { definitionVersion: string; steps: BackendStepState[]; draftValues?: Record; + secretDrafts?: Array<{ + key: string; + stepId?: string | null; + updatedAtUtc: string; + }>; createdAtUtc: string; updatedAtUtc: string; completedAtUtc?: string | null; @@ -300,6 +306,7 @@ export class SetupWizardApiService { steps, completedSteps, skippedSteps, + secretDrafts: (session.secretDrafts ?? []).map(secret => this.mapSecretDraft(secret)), }; } @@ -317,6 +324,14 @@ export class SetupWizardApiService { }; } + private mapSecretDraft(secret: NonNullable[number]): SetupSecretDraft { + return { + key: secret.key, + stepId: secret.stepId ? this.toFrontendStepId(secret.stepId) : null, + updatedAt: secret.updatedAtUtc, + }; + } + private mapStepResult(data: WrappedMutationResponse['data'], requestedStepId: SetupStepId): SetupStepResult { return { stepId: requestedStepId, diff --git a/src/Web/StellaOps.Web/src/app/features/topology/environments-command.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/environments-command.component.ts index cc68996a9..6b2cf50cc 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/environments-command.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/environments-command.component.ts @@ -59,151 +59,6 @@ interface Environment { environmentType?: string; } -// ── Mock data ─────────────────────────────────────────────────────── - -/* -const MOCK_REPORTS: ReadinessReport[] = [ - t('tgt-eu-p-a1','eu-prod-app-01','eu-prod',true), t('tgt-eu-p-a2','eu-prod-app-02','eu-prod',true), - t('tgt-eu-p-w1','eu-prod-worker-01','eu-prod',false,[ - g('agent_bound','pass','Agent heartbeat OK'),g('docker_version_ok','pass','Docker 24.0.9'), - g('docker_ping_ok','pass','Daemon reachable'),g('registry_pull_ok','pass','Pull test OK'), - g('vault_reachable','fail','Vault sealed — manual unseal required',5100), - g('consul_reachable','pass','Consul leader elected'),g('connectivity_ok','fail','Required gate vault_reachable failed')]), - t('tgt-eu-s-a1','eu-stage-app-01','eu-stage',true), - t('tgt-eu-s-a2','eu-stage-app-02','eu-stage',false,[ - g('agent_bound','pass','Agent heartbeat OK'),g('docker_version_ok','pass','Docker 24.0.9'), - g('docker_ping_ok','pass','Daemon reachable'), - g('registry_pull_ok','fail','Connection refused: registry.internal:5000',3200), - g('vault_reachable','pass','Vault unsealed'),g('consul_reachable','pass','Consul leader elected'), - g('connectivity_ok','fail','Required gate registry_pull_ok failed')]), - t('tgt-eu-s-w1','eu-stage-worker-01','eu-stage',true,[ - g('agent_bound','pass','Agent heartbeat OK'),g('docker_version_ok','pass','Docker 24.0.9'), - g('docker_ping_ok','pass','Daemon reachable'),g('registry_pull_ok','pass','Pull test OK'), - g('vault_reachable','skip','No vault binding'),g('consul_reachable','skip','No consul binding'), - g('connectivity_ok','pass','All required gates pass')]), - t('tgt-us-p-a1','us-prod-app-01','us-prod',true), t('tgt-us-p-a2','us-prod-app-02','us-prod',true), - t('tgt-us-p-w1','us-prod-worker-01','us-prod',true), - t('tgt-us-u-a1','us-uat-app-01','us-uat',true), - t('tgt-us-u-w1','us-uat-worker-01','us-uat',false,[ - g('agent_bound','pending','Awaiting agent registration'),g('docker_version_ok','pending','Blocked by agent_bound'), - g('docker_ping_ok','pending','Blocked by agent_bound'),g('registry_pull_ok','pending','Blocked by agent_bound'), - g('vault_reachable','pending','Blocked by agent_bound'),g('consul_reachable','pending','Blocked by agent_bound'), - g('connectivity_ok','fail','Required gate agent_bound is pending')]), - t('tgt-uw-p-a1','usw-prod-app-01','prod-us-west',true), t('tgt-uw-p-w1','usw-prod-worker-01','prod-us-west',true), - t('tgt-ed-a1','eudr-app-01','prod-eu-west',true), - t('tgt-ed-w1','eudr-worker-01','prod-eu-west',false,[ - g('agent_bound','pass','Agent heartbeat OK'), - g('docker_version_ok','fail','Docker 19.03.15 — minimum 20.10 required',120), - g('docker_ping_ok','pass','Daemon reachable'),g('registry_pull_ok','pass','Pull test OK'), - g('vault_reachable','pass','Vault unsealed'),g('consul_reachable','pass','Consul leader elected'), - g('connectivity_ok','fail','Required gate docker_version_ok failed')]), - t('tgt-ap-a1','apac-prod-app-01','apac-prod',true), t('tgt-ap-a2','apac-prod-app-02','apac-prod',true), - t('tgt-ap-w1','apac-prod-worker-01','apac-prod',false,[ - g('agent_bound','pass','Agent heartbeat OK'),g('docker_version_ok','pass','Docker 25.0.3'), - g('docker_ping_ok','pass','Daemon reachable'),g('registry_pull_ok','pass','Pull test OK'), - g('vault_reachable','pass','Vault unsealed'), - g('consul_reachable','fail','No Consul leader — cluster partitioned',8200), - g('connectivity_ok','fail','Required gate consul_reachable failed')]), -]; - -const REMEDIATION: Record = { - agent_bound: 'Register an agent via Ops → Agent Fleet.', - docker_version_ok: 'Upgrade Docker on the host to 20.10+.', - docker_ping_ok: 'Check Docker daemon is running and accessible.', - registry_pull_ok: 'Verify registry connectivity and credentials.', - vault_reachable: 'Unseal Vault or check network path.', - consul_reachable: 'Check Consul cluster health and partitions.', - connectivity_ok: 'Fix the upstream gate failures listed above.', -}; - -// Mock topology layout for when API returns empty -function buildMockLayout(envs: Environment[], reports: ReadinessReport[]): TopologyLayoutResponse { - const readinessMap = new Map(); - for (const env of envs) { - const er = reports.filter(r => r.environmentId === env.environmentId); - readinessMap.set(env.environmentId, er.length > 0 && er.every(r => r.isReady) ? 'healthy' : er.some(r => !r.isReady) ? 'degraded' : 'unknown'); - } - - const nodes: TopologyPositionedNode[] = []; - const edges: TopologyRoutedEdge[] = []; - - // Region boxes - const regionLayout: Record = { - 'eu-west': { x: 0, y: 0, w: 520, h: 230 }, - 'us-east': { x: 560, y: 0, w: 460, h: 150 }, - 'us-west': { x: 560, y: 190, w: 250, h: 130 }, - 'apac': { x: 0, y: 270, w: 300, h: 130 }, - }; - - for (const r of REGIONS) { - const box = regionLayout[r.regionId]; - if (!box) continue; - nodes.push({ - id: `region-${r.regionId}`, label: r.displayName, kind: 'region', parentNodeId: null, - x: box.x, y: box.y, width: box.w, height: box.h, - hostCount: 0, targetCount: 0, isFrozen: false, promotionPathCount: 0, - deployingCount: 0, pendingCount: 0, failedCount: 0, totalDeployments: 0, - }); - } - - // Environment nodes - const envPositions: Record = { - 'eu-stage': { x: 30, y: 60 }, - 'eu-prod': { x: 260, y: 60 }, - 'prod-eu-west': { x: 260, y: 150 }, - 'us-uat': { x: 590, y: 55 }, - 'us-prod': { x: 800, y: 55 }, - 'prod-us-west': { x: 590, y: 235 }, - 'apac-prod': { x: 30, y: 315 }, - }; - - for (const env of envs) { - const pos = envPositions[env.environmentId]; - if (!pos) continue; - const er = reports.filter(r => r.environmentId === env.environmentId); - nodes.push({ - id: `env-${env.environmentId}`, label: env.displayName, kind: 'environment', - parentNodeId: `region-${env.regionId}`, - x: pos.x, y: pos.y, width: 180, height: 55, - environmentId: env.environmentId, regionId: env.regionId, - environmentType: env.environmentType, - healthStatus: readinessMap.get(env.environmentId) ?? 'unknown', - hostCount: er.length, targetCount: er.length, - isFrozen: false, promotionPathCount: 0, - deployingCount: 0, pendingCount: 0, failedCount: 0, totalDeployments: 0, - }); - } - - // Promotion path edges - const paths: Array<{ from: string; to: string; mode: string }> = [ - { from: 'eu-stage', to: 'eu-prod', mode: 'auto' }, - { from: 'eu-prod', to: 'prod-eu-west', mode: 'manual' }, - { from: 'us-uat', to: 'us-prod', mode: 'auto' }, - { from: 'us-prod', to: 'prod-us-west', mode: 'manual' }, - ]; - - for (const p of paths) { - const src = envPositions[p.from]; - const dst = envPositions[p.to]; - if (!src || !dst) continue; - edges.push({ - id: `path-${p.from}-${p.to}`, - sourceNodeId: `env-${p.from}`, targetNodeId: `env-${p.to}`, - kind: 'promotion', label: p.mode === 'auto' ? 'auto-promote' : 'manual', - pathMode: p.mode, status: 'active', requiredApprovals: p.mode === 'manual' ? 1 : 0, - sections: [{ startPoint: { x: src.x + 180, y: src.y + 27 }, endPoint: { x: dst.x, y: dst.y + 27 }, bendPoints: [] }], - }); - } - - return { - nodes, edges, - metadata: { regionCount: 4, environmentCount: envs.length, promotionPathCount: paths.length, canvasWidth: 1060, canvasHeight: 420 }, - }; -} - -// ── Component ─────────────────────────────────────────────────────── - -*/ const REMEDIATION: Record = { agent_bound: 'Register an agent via Ops > Agent Fleet.', diff --git a/src/Web/StellaOps.Web/src/app/shared/components/manifest-validator.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/manifest-validator.component.ts index 53678acea..f80667f9d 100644 --- a/src/Web/StellaOps.Web/src/app/shared/components/manifest-validator.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/components/manifest-validator.component.ts @@ -1,16 +1,23 @@ // Manifest Validator Component // Sprint 026: Offline Kit Integration -import { Component, ChangeDetectionStrategy, signal, output, inject } from '@angular/core'; +import { Component, ChangeDetectionStrategy, signal, output } from '@angular/core'; import { + OfflineManifest, BundleValidationResult, ValidationError, ValidationWarning, - AssetIntegrityReport, SignatureStatus } from '../../core/api/offline-kit.models'; +export interface ValidatedManifestPayload { + fileName: string; + fileSize: number; + manifest: OfflineManifest | null; + result: BundleValidationResult; +} + @Component({ selector: 'app-manifest-validator', imports: [], @@ -417,7 +424,7 @@ import { `] }) export class ManifestValidatorComponent { - readonly validated = output(); + readonly validated = output(); readonly isDragOver = signal(false); readonly isValidating = signal(false); @@ -458,30 +465,34 @@ export class ManifestValidatorComponent { this.validationResult.set(null); try { - // Simulate validation - in production this would call backend - await this.delay(1500); - - const result = await this.validateManifest(file); - this.validationResult.set(result); - this.validated.emit(result); + const payload = await this.validateManifest(file); + this.validationResult.set(payload.result); + this.validated.emit(payload); } catch (error) { - this.validationResult.set({ - valid: false, - errors: [{ code: 'PARSE_ERROR', message: 'Failed to parse manifest file' }], - warnings: [], - assetIntegrity: { totalAssets: 0, validAssets: 0, invalidAssets: 0, missingAssets: [], hashMismatches: [] }, - signatureStatus: { valid: false, algorithm: 'unknown', error: 'Could not verify signature' } - }); + const payload: ValidatedManifestPayload = { + fileName: file.name, + fileSize: file.size, + manifest: null, + result: { + valid: false, + errors: [{ code: 'PARSE_ERROR', message: 'Failed to parse manifest file' }], + warnings: [], + assetIntegrity: { totalAssets: 0, validAssets: 0, invalidAssets: 0, missingAssets: [], hashMismatches: [] }, + signatureStatus: { valid: false, algorithm: 'unknown', error: 'Could not verify signature' } + }, + }; + this.validationResult.set(payload.result); + this.validated.emit(payload); } finally { this.isValidating.set(false); } } - private async validateManifest(file: File): Promise { + private async validateManifest(file: File): Promise { const content = await file.text(); try { - const manifest = JSON.parse(content); + const manifest = JSON.parse(content) as Partial; // Basic validation const errors: ValidationError[] = []; @@ -515,7 +526,7 @@ export class ManifestValidatorComponent { } } - return { + const result: BundleValidationResult = { valid: errors.length === 0, errors, warnings, @@ -532,7 +543,14 @@ export class ManifestValidatorComponent { keyId: manifest.signature ? 'authority-key-001' : undefined, signedAt: manifest.createdAt, error: manifest.signature ? undefined : 'No signature present' - } + }, + }; + + return { + fileName: file.name, + fileSize: file.size, + manifest: result.valid ? this.toOfflineManifest(manifest) : null, + result, }; } catch { throw new Error('Invalid JSON'); @@ -550,8 +568,18 @@ export class ManifestValidatorComponent { if (hash.length <= 20) return hash; return `${hash.slice(0, 12)}...${hash.slice(-8)}`; } - - private delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); + private toOfflineManifest(manifest: Partial): OfflineManifest { + return { + version: manifest.version ?? '', + createdAt: manifest.createdAt ?? '', + expiresAt: manifest.expiresAt ?? '', + signature: manifest.signature ?? '', + assets: { + ui: manifest.assets?.ui ?? {}, + api_contracts: manifest.assets?.api_contracts ?? {}, + authority: manifest.assets?.authority ?? {}, + feeds: manifest.assets?.feeds ?? {}, + }, + }; } } diff --git a/src/Web/StellaOps.Web/src/tests/configuration_pane/configuration-pane-api.service.spec.ts b/src/Web/StellaOps.Web/src/tests/configuration_pane/configuration-pane-api.service.spec.ts new file mode 100644 index 000000000..88164a13b --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/configuration_pane/configuration-pane-api.service.spec.ts @@ -0,0 +1,180 @@ +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom, of } from 'rxjs'; + +import { AuditLogClient } from '../../app/core/api/audit-log.client'; +import { IntegrationService } from '../../app/features/integration-hub/integration.service'; +import { + HealthStatus, + Integration, + IntegrationProvider, + IntegrationStatus, + IntegrationType as HubIntegrationType, +} from '../../app/features/integration-hub/integration.models'; +import { ConfigurationPaneApiService } from '../../app/features/configuration-pane/services/configuration-pane-api.service'; + +describe('ConfigurationPaneApiService', () => { + let service: ConfigurationPaneApiService; + let integrations: jasmine.SpyObj; + let auditLog: jasmine.SpyObj; + + const registryIntegration: Integration = { + id: 'reg-1', + name: 'Harbor Registry', + description: 'Primary OCI registry', + type: HubIntegrationType.Registry, + provider: IntegrationProvider.Harbor, + status: IntegrationStatus.Active, + endpoint: 'https://harbor.internal', + hasAuth: true, + organizationId: 'ops', + lastHealthStatus: HealthStatus.Healthy, + lastHealthCheckAt: '2026-04-15T10:00:00Z', + createdAt: '2026-04-10T09:00:00Z', + updatedAt: '2026-04-15T10:00:00Z', + createdBy: 'admin@stellaops.local', + updatedBy: 'admin@stellaops.local', + tags: ['prod', 'internal'], + }; + + const vaultIntegration: Integration = { + id: 'vault-1', + name: 'Secrets Vault', + description: 'HashiCorp Vault', + type: HubIntegrationType.SecretsManager, + provider: IntegrationProvider.Vault, + status: IntegrationStatus.Active, + endpoint: 'https://vault.internal', + hasAuth: true, + organizationId: null, + lastHealthStatus: HealthStatus.Degraded, + lastHealthCheckAt: '2026-04-15T10:05:00Z', + createdAt: '2026-04-12T09:00:00Z', + updatedAt: '2026-04-15T10:05:00Z', + createdBy: 'admin@stellaops.local', + updatedBy: 'admin@stellaops.local', + tags: [], + }; + + const unsupportedIntegration: Integration = { + id: 'storage-1', + name: 'S3 Archive', + description: 'Object storage', + type: HubIntegrationType.ObjectStorage, + provider: IntegrationProvider.S3Compatible, + status: IntegrationStatus.Active, + endpoint: 'https://s3.internal', + hasAuth: true, + organizationId: null, + lastHealthStatus: HealthStatus.Healthy, + lastHealthCheckAt: null, + createdAt: '2026-04-01T09:00:00Z', + updatedAt: '2026-04-15T09:00:00Z', + createdBy: 'ops@stellaops.local', + updatedBy: 'ops@stellaops.local', + tags: [], + }; + + beforeEach(() => { + integrations = jasmine.createSpyObj('IntegrationService', [ + 'list', + 'get', + 'update', + 'testConnection', + 'getHealth', + 'delete', + ]); + auditLog = jasmine.createSpyObj('AuditLogClient', ['getEvents']); + + TestBed.configureTestingModule({ + providers: [ + ConfigurationPaneApiService, + { provide: IntegrationService, useValue: integrations }, + { provide: AuditLogClient, useValue: auditLog }, + ], + }); + + service = TestBed.inject(ConfigurationPaneApiService); + }); + + it('maps live integration inventory into configuration-pane sections without inventing database or cache rows', async () => { + integrations.list.and.returnValue(of({ + items: [registryIntegration, vaultIntegration, unsupportedIntegration], + totalCount: 3, + page: 1, + pageSize: 200, + totalPages: 1, + })); + + const result = await firstValueFrom(service.getIntegrations()); + + expect(result.map((integration) => integration.id)).toEqual(['reg-1', 'vault-1']); + expect(result.map((integration) => integration.type)).toEqual(['registry', 'vault']); + expect(result.some((integration) => integration.type === 'database')).toBeFalse(); + expect(result[0].configValues['integration.endpoint']).toBe('https://harbor.internal'); + }); + + it('updates only writable integration fields exposed by the live integrations API', async () => { + integrations.get.and.returnValue(of(registryIntegration)); + integrations.update.and.returnValue(of({ + ...registryIntegration, + endpoint: 'https://harbor.internal/v2', + tags: ['prod', 'mirror'], + })); + + const result = await firstValueFrom(service.updateConfiguration({ + integrationId: 'reg-1', + configValues: { + 'integration.endpoint': 'https://harbor.internal/v2', + 'integration.tags': 'prod, mirror', + }, + })); + + expect(integrations.update).toHaveBeenCalledWith('reg-1', { + endpoint: 'https://harbor.internal/v2', + tags: ['prod', 'mirror'], + }); + expect(result.success).toBeTrue(); + }); + + it('maps connection tests and audit history from real upstream clients', async () => { + integrations.testConnection.and.returnValue(of({ + integrationId: 'reg-1', + success: true, + message: 'Connected', + details: { roundTrip: 'ok' }, + duration: '125ms', + testedAt: '2026-04-15T10:12:00Z', + })); + auditLog.getEvents.and.returnValue(of({ + items: [ + { + id: 'evt-1', + timestamp: '2026-04-15T09:00:00Z', + module: 'integrations', + action: 'integration.updated', + severity: 'info', + actor: { type: 'user', id: 'admin', name: 'admin@stellaops.local' }, + resource: { type: 'integration', id: 'reg-1', name: 'Harbor Registry' }, + description: 'Integration updated', + details: { + integrationId: 'reg-1', + integrationName: 'Harbor Registry', + changedFields: ['integration.endpoint'], + }, + tags: [], + }, + ], + cursor: null, + hasMore: false, + })); + + const testResult = await firstValueFrom(service.testConnection('reg-1')); + const history = await firstValueFrom(service.getHistory('reg-1')); + + expect(testResult.latencyMs).toBe(125); + expect(testResult.success).toBeTrue(); + expect(history[0].integrationId).toBe('reg-1'); + expect(history[0].action).toBe('updated'); + expect(history[0].performedBy).toBe('admin@stellaops.local'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/tests/configuration_pane/configuration-pane.component.spec.ts b/src/Web/StellaOps.Web/src/tests/configuration_pane/configuration-pane.component.spec.ts index e5bff80ad..a3a024469 100644 --- a/src/Web/StellaOps.Web/src/tests/configuration_pane/configuration-pane.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/configuration_pane/configuration-pane.component.spec.ts @@ -5,111 +5,96 @@ import { of } from 'rxjs'; import { ConfigurationPaneComponent } from '../../app/features/configuration-pane/components/configuration-pane.component'; import { ConfigurationPaneApiService } from '../../app/features/configuration-pane/services/configuration-pane-api.service'; import { ConfigurationPaneStateService } from '../../app/features/configuration-pane/services/configuration-pane-state.service'; -import { - ConfiguredIntegration, - ConfigurationCheck, -} from '../../app/features/configuration-pane/models/configuration-pane.models'; +import { ConfiguredIntegration, ConfigurationCheck } from '../../app/features/configuration-pane/models/configuration-pane.models'; -describe('ConfigurationPaneComponent (configuration_pane)', () => { +describe('ConfigurationPaneComponent truthful state', () => { let fixture: ComponentFixture; let component: ConfigurationPaneComponent; - let api: { - getIntegrations: jasmine.Spy; - getChecks: jasmine.Spy; - testConnection: jasmine.Spy; - refreshStatus: jasmine.Spy; - updateConfiguration: jasmine.Spy; - removeIntegration: jasmine.Spy; - runChecksForIntegration: jasmine.Spy; - exportConfiguration: jasmine.Spy; - }; - let router: { - navigate: jasmine.Spy; - }; + let api: jasmine.SpyObj; - const integrations: ConfiguredIntegration[] = [ + const liveIntegrations: ConfiguredIntegration[] = [ { - id: 'db-primary', - type: 'database', - name: 'Primary Database', - provider: 'postgresql', + id: 'reg-1', + type: 'registry', + name: 'Harbor Registry', + provider: 'Harbor', + description: 'Primary OCI registry', status: 'connected', healthStatus: 'healthy', - configuredAt: '2026-02-10T00:00:00Z', - configValues: { 'database.host': 'localhost' }, - isPrimary: true, + lastChecked: '2026-04-15T10:00:00Z', + configuredAt: '2026-04-10T09:00:00Z', + configuredBy: 'admin@stellaops.local', + configValues: { + 'integration.endpoint': 'https://harbor.internal', + 'integration.tags': 'prod, internal', + }, }, ]; - const checks: ConfigurationCheck[] = [ + const liveChecks: ConfigurationCheck[] = [ { - checkId: 'check.database.connectivity', - integrationId: 'db-primary', - name: 'Database Connectivity', + checkId: 'health.reg-1', + integrationId: 'reg-1', + name: 'Connection Health', status: 'passed', + message: 'Last known health is healthy.', severity: 'critical', - message: 'Connection established', + lastRun: '2026-04-15T10:00:00Z', }, ]; beforeEach(async () => { - api = { - getIntegrations: jasmine.createSpy('getIntegrations'), - getChecks: jasmine.createSpy('getChecks'), - testConnection: jasmine.createSpy('testConnection'), - refreshStatus: jasmine.createSpy('refreshStatus'), - updateConfiguration: jasmine.createSpy('updateConfiguration'), - removeIntegration: jasmine.createSpy('removeIntegration'), - runChecksForIntegration: jasmine.createSpy('runChecksForIntegration'), - exportConfiguration: jasmine.createSpy('exportConfiguration'), - }; - router = { - navigate: jasmine.createSpy('navigate'), - }; - - api.getIntegrations.and.returnValue(of(integrations)); - api.getChecks.and.returnValue(of(checks)); - api.testConnection.and.returnValue(of({ success: true, message: 'Connected', latencyMs: 20 })); + api = jasmine.createSpyObj('ConfigurationPaneApiService', [ + 'getIntegrations', + 'getChecks', + 'testConnection', + 'refreshStatus', + 'updateConfiguration', + 'removeIntegration', + 'runChecksForIntegration', + 'exportConfiguration', + ]); await TestBed.configureTestingModule({ imports: [ConfigurationPaneComponent], providers: [ ConfigurationPaneStateService, - { provide: ConfigurationPaneApiService, useValue: api as unknown as ConfigurationPaneApiService }, - { provide: Router, useValue: router as unknown as Router }, + { provide: ConfigurationPaneApiService, useValue: api }, + { provide: Router, useValue: jasmine.createSpyObj('Router', ['navigate']) }, ], }).compileComponents(); + }); + function createComponent(): void { fixture = TestBed.createComponent(ConfigurationPaneComponent); component = fixture.componentInstance; - }); + } - it('loads integrations and checks on initialization', async () => { + it('shows the truthful empty state when no live integrations are returned', () => { + api.getIntegrations.and.returnValue(of([])); + api.getChecks.and.returnValue(of([])); + + createComponent(); fixture.detectChanges(); - await fixture.whenStable(); - - expect(api.getIntegrations).toHaveBeenCalledTimes(1); - expect(api.getChecks).toHaveBeenCalledTimes(1); - expect(component.state.summary().totalIntegrations).toBe(1); - expect(component.state.summary().healthyIntegrations).toBe(1); - }); - - it('navigates to setup wizard from action handler', () => { - component.navigateToSetupWizard(); - expect(router.navigate).toHaveBeenCalledWith(['/setup']); - }); - - it('runs connection test and surfaces success message', async () => { fixture.detectChanges(); - await fixture.whenStable(); - component.onTestConnection(integrations[0]); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('No Integrations Configured'); + expect(compiled.querySelectorAll('.integration-card').length).toBe(0); + expect(compiled.textContent).not.toContain('Primary Database'); + }); - expect(api.testConnection).toHaveBeenCalledWith({ - integrationType: 'database', - provider: 'postgresql', - configValues: { 'database.host': 'localhost' }, - }); - expect(component.state.successMessage()).toContain('Connection successful'); + it('renders only live configuration rows instead of seeded mock integrations', () => { + api.getIntegrations.and.returnValue(of(liveIntegrations)); + api.getChecks.and.returnValue(of(liveChecks)); + + createComponent(); + fixture.detectChanges(); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('Harbor Registry'); + expect(compiled.textContent).not.toContain('Primary Database'); + expect(compiled.querySelector('.summary-card .summary-value')?.textContent?.trim()).toBe('1'); }); }); diff --git a/src/Web/StellaOps.Web/src/tests/deployments/create-deployment.component.spec.ts b/src/Web/StellaOps.Web/src/tests/deployments/create-deployment.component.spec.ts deleted file mode 100644 index 7d728cb8d..000000000 --- a/src/Web/StellaOps.Web/src/tests/deployments/create-deployment.component.spec.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router'; -import { BehaviorSubject, of, throwError } from 'rxjs'; - -import { DEPLOYMENT_API } from '../../app/core/api/deployment.client'; -import type { ManagedRelease, RegistryImage } from '../../app/core/api/release-management.models'; -import { PlatformContextStore } from '../../app/core/context/platform-context.store'; -import { BundleOrganizerApi } from '../../app/features/bundles/bundle-organizer.api'; -import { CreateDeploymentComponent } from '../../app/features/release-orchestrator/releases/create-deployment/create-deployment.component'; -import { ReleaseManagementStore } from '../../app/features/release-orchestrator/releases/release.store'; - -const RELEASE: ManagedRelease = { - id: 'rel-1', - name: 'checkout-api', - version: '2026.04.05', - description: 'Checkout API release', - status: 'ready', - releaseType: 'standard', - slug: 'checkout-api', - digest: 'sha256:release', - currentStage: null, - currentEnvironment: null, - targetEnvironment: 'env-prod-eu', - targetRegion: 'eu', - componentCount: 1, - gateStatus: 'pass', - gateBlockingCount: 0, - gatePendingApprovals: 0, - gateBlockingReasons: [], - riskCriticalReachable: 0, - riskHighReachable: 0, - riskTrend: 'stable', - riskTier: 'low', - evidencePosture: 'verified', - needsApproval: false, - blocked: false, - hotfixLane: false, - replayMismatch: false, - createdAt: '2026-04-05T10:00:00Z', - createdBy: 'qa', - updatedAt: '2026-04-05T10:00:00Z', - lastActor: 'qa', - deployedAt: null, - deploymentStrategy: 'rolling', -}; - -describe('CreateDeploymentComponent', () => { - let fixture: ComponentFixture; - let component: CreateDeploymentComponent; - let router: Router; - let queryParamMap$: BehaviorSubject>; - let releaseSignal: ReturnType>; - let searchResultsSignal: ReturnType>; - let bundleApi: { - listBundleVersions: jasmine.Spy; - getBundleVersion: jasmine.Spy; - createBundle: jasmine.Spy; - publishBundleVersion: jasmine.Spy; - materializeBundleVersion: jasmine.Spy; - listBundles: jasmine.Spy; - }; - let deploymentApi: { - createDeployment: jasmine.Spy; - }; - - beforeEach(async () => { - queryParamMap$ = new BehaviorSubject(convertToParamMap({ releaseId: RELEASE.id })); - releaseSignal = signal(RELEASE); - searchResultsSignal = signal([]); - - bundleApi = { - listBundleVersions: jasmine.createSpy('listBundleVersions').and.returnValue(of([ - { - id: 'ver-1', - bundleId: RELEASE.id, - versionNumber: 7, - digest: 'sha256:bundle-version', - status: 'published', - componentsCount: 1, - changelog: null, - createdAt: '2026-04-05T11:00:00Z', - publishedAt: '2026-04-05T11:05:00Z', - createdBy: 'qa', - }, - ])), - getBundleVersion: jasmine.createSpy('getBundleVersion').and.returnValue(of({ - id: 'ver-1', - bundleId: RELEASE.id, - versionNumber: 7, - digest: 'sha256:bundle-version', - status: 'published', - componentsCount: 1, - changelog: null, - createdAt: '2026-04-05T11:00:00Z', - publishedAt: '2026-04-05T11:05:00Z', - createdBy: 'qa', - components: [ - { - componentVersionId: 'checkout-api@2026.04.05', - componentName: 'checkout-api', - imageDigest: 'sha256:image', - deployOrder: 10, - metadataJson: '{}', - }, - ], - })), - createBundle: jasmine.createSpy('createBundle'), - publishBundleVersion: jasmine.createSpy('publishBundleVersion'), - materializeBundleVersion: jasmine.createSpy('materializeBundleVersion'), - listBundles: jasmine.createSpy('listBundles'), - }; - - deploymentApi = { - createDeployment: jasmine.createSpy('createDeployment').and.returnValue(of({ - id: 'dep-123', - releaseId: RELEASE.id, - releaseName: RELEASE.name, - releaseVersion: RELEASE.version, - environmentId: 'env-prod-eu', - environmentName: 'EU Production', - status: 'pending', - strategy: 'all_at_once', - progress: 0, - startedAt: '2026-04-05T11:10:00Z', - completedAt: null, - initiatedBy: 'qa', - targetCount: 4, - completedTargets: 0, - failedTargets: 0, - targets: [], - currentStep: 'Queued for rollout', - canPause: false, - canResume: false, - canCancel: true, - canRollback: false, - })), - }; - - await TestBed.configureTestingModule({ - imports: [CreateDeploymentComponent], - providers: [ - provideRouter([]), - { - provide: ActivatedRoute, - useValue: { - queryParamMap: queryParamMap$.asObservable(), - }, - }, - { - provide: ReleaseManagementStore, - useValue: { - selectedRelease: releaseSignal, - searchResults: searchResultsSignal, - selectRelease: jasmine.createSpy('selectRelease').and.callFake((releaseId: string) => { - releaseSignal.set(releaseId === RELEASE.id ? RELEASE : null); - }), - searchImages: jasmine.createSpy('searchImages'), - clearSearchResults: jasmine.createSpy('clearSearchResults').and.callFake(() => searchResultsSignal.set([])), - }, - }, - { - provide: PlatformContextStore, - useValue: { - initialize: jasmine.createSpy('initialize'), - regions: signal([ - { regionId: 'eu', displayName: 'Europe', sortOrder: 1, enabled: true }, - ]), - environments: signal([ - { - environmentId: 'env-prod-eu', - regionId: 'eu', - environmentType: 'production', - displayName: 'EU Production', - sortOrder: 1, - enabled: true, - }, - ]), - }, - }, - { provide: BundleOrganizerApi, useValue: bundleApi }, - { provide: DEPLOYMENT_API, useValue: deploymentApi }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(CreateDeploymentComponent); - component = fixture.componentInstance; - router = TestBed.inject(Router); - spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); - fixture.detectChanges(); - await fixture.whenStable(); - fixture.detectChanges(); - }); - - it('loads live bundle versions for the linked release', () => { - expect(component.linkedRelease()?.id).toBe(RELEASE.id); - expect(bundleApi.listBundleVersions).toHaveBeenCalledWith(RELEASE.id, 50, 0); - expect(bundleApi.getBundleVersion).toHaveBeenCalledWith(RELEASE.id, 'ver-1'); - expect(component.availableVersions().length).toBe(1); - expect(component.availableVersions()[0].version).toBe('Version 7'); - }); - - it('submits a live deployment create request and navigates to the created deployment', () => { - component.selectVersion(component.availableVersions()[0]); - component.promotionStages[0].environmentId = 'env-prod-eu'; - component.promotionStages[1].environmentId = ''; - component.promotionStages[2].environmentId = ''; - component.deploymentStrategy = 'all_at_once'; - component.createConfirmed = true; - - component.createDeployment(); - - expect(deploymentApi.createDeployment).toHaveBeenCalledWith(jasmine.objectContaining({ - releaseId: RELEASE.id, - environmentId: 'env-prod-eu', - environmentName: 'EU Production', - strategy: 'all_at_once', - packageType: 'version', - packageRefId: 'ver-1', - packageRefName: RELEASE.name, - promotionStages: [{ name: 'Development', environmentId: 'env-prod-eu' }], - })); - expect(router.navigate).toHaveBeenCalledWith( - ['/releases/deployments', 'dep-123'], - { queryParamsHandling: 'merge' }, - ); - }); - - it('surfaces backend errors instead of pretending deployment creation succeeded', () => { - deploymentApi.createDeployment.and.returnValue(throwError(() => ({ status: 503 }))); - component.selectVersion(component.availableVersions()[0]); - component.promotionStages[0].environmentId = 'env-prod-eu'; - component.promotionStages[1].environmentId = ''; - component.promotionStages[2].environmentId = ''; - component.createConfirmed = true; - - component.createDeployment(); - - expect(component.submitError()).toContain('backend is unavailable'); - expect(router.navigate).not.toHaveBeenCalled(); - }); -}); diff --git a/src/Web/StellaOps.Web/src/tests/export_center/export-center.component.spec.ts b/src/Web/StellaOps.Web/src/tests/export_center/export-center.component.spec.ts new file mode 100644 index 000000000..bc44beb2b --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/export_center/export-center.component.spec.ts @@ -0,0 +1,211 @@ +import { computed } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; +import { BehaviorSubject, of } from 'rxjs'; + +import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../app/core/api/audit-bundles.client'; +import { EXPORT_CENTER_API, type ExportCenterApi } from '../../app/core/api/export-center.client'; +import { ViewModeService } from '../../app/core/services/view-mode.service'; +import { ExportCenterComponent } from '../../app/features/evidence-export/export-center.component'; +import { StellaBundleExportResult } from '../../app/features/evidence-export/evidence-export.models'; + +describe('ExportCenterComponent', () => { + let fixture: ComponentFixture; + let component: ExportCenterComponent; + let mockRouter: { navigate: jasmine.Spy }; + let mockAuditBundlesApi: jasmine.SpyObj; + let mockExportApi: jasmine.SpyObj; + let queryParamMap$: BehaviorSubject>; + + const mockViewModeService = { + isOperator: computed(() => true), + isAuditor: computed(() => false), + }; + + beforeEach(async () => { + mockRouter = { + navigate: jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)), + }; + mockAuditBundlesApi = jasmine.createSpyObj('AuditBundlesApi', [ + 'listBundles', + 'createBundle', + 'getBundle', + 'downloadBundle', + ]); + mockExportApi = jasmine.createSpyObj('ExportCenterApi', [ + 'listProfiles', + 'startRun', + 'getRun', + 'streamRun', + 'getDistribution', + ]); + queryParamMap$ = new BehaviorSubject(convertToParamMap({ artifactId: 'artifact-123' })); + + mockExportApi.listProfiles.and.returnValue(of({ + items: [ + { + profileId: 'profile-1', + name: 'Daily VEX Export', + description: 'Exports VEX and advisory data.', + targets: ['vex', 'advisory'], + formats: ['json'], + schedule: '0 2 * * *', + createdAt: '2026-04-15T00:00:00Z', + }, + ], + total: 1, + })); + mockExportApi.startRun.and.returnValue(of({ + runId: 'run-1', + status: 'queued', + profileId: 'profile-1', + })); + mockExportApi.getRun.and.returnValue(of({ + runId: 'run-1', + status: 'succeeded', + profileId: 'profile-1', + startedAt: '2026-04-15T10:00:00Z', + completedAt: '2026-04-15T10:02:00Z', + outputs: [ + { + type: 'manifest', + format: 'json', + url: '/exports/run-1.json', + }, + ], + progress: { + percent: 100, + itemsCompleted: 12, + itemsTotal: 12, + }, + })); + mockExportApi.streamRun.and.returnValue(of({ + event: 'completed', + runId: 'run-1', + status: 'succeeded', + manifestUrl: '/exports/run-1.json', + })); + mockExportApi.getDistribution.and.returnValue(of({ + distributionId: 'dist-1', + type: 'object-storage', + url: 'https://downloads.example.com/run-1.json', + expiresAt: '2026-04-15T12:00:00Z', + })); + + await TestBed.configureTestingModule({ + imports: [ExportCenterComponent], + providers: [ + { provide: Router, useValue: mockRouter }, + { provide: AUDIT_BUNDLES_API, useValue: mockAuditBundlesApi }, + { provide: EXPORT_CENTER_API, useValue: mockExportApi }, + { provide: ViewModeService, useValue: mockViewModeService }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { queryParamMap: queryParamMap$.value }, + queryParamMap: queryParamMap$.asObservable(), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ExportCenterComponent); + component = fixture.componentInstance; + }); + + it('loads export profiles from the API on init', () => { + fixture.detectChanges(); + + expect(mockExportApi.listProfiles).toHaveBeenCalled(); + expect(component.profiles().length).toBe(1); + expect(component.profiles()[0].name).toBe('Daily VEX Export'); + expect(component.profiles()[0].includeOptions.vexDecisions).toBeTrue(); + }); + + it('does not seed fake runs before the operator starts one', () => { + fixture.detectChanges(); + + expect(component.runs()).toEqual([]); + }); + + it('reads the selected artifact from query params instead of a hardcoded demo id', () => { + fixture.detectChanges(); + + expect(component.selectedArtifactId()).toBe('artifact-123'); + + queryParamMap$.next(convertToParamMap({ artifactId: 'artifact-456' })); + + expect(component.selectedArtifactId()).toBe('artifact-456'); + }); + + it('starts a real export run and tracks the returned status', () => { + fixture.detectChanges(); + + component.runProfile(component.profiles()[0]); + + expect(mockExportApi.startRun).toHaveBeenCalledWith({ + profileId: 'profile-1', + targets: ['vex', 'advisory'], + formats: ['json'], + }); + expect(mockExportApi.getRun).toHaveBeenCalledWith('run-1'); + expect(mockExportApi.streamRun).toHaveBeenCalledWith('run-1'); + expect(component.activeTab()).toBe('runs'); + expect(component.runs()[0].status).toBe('completed'); + expect(component.runs()[0].outputPath).toBe('/exports/run-1.json'); + }); + + it('only allows retry for failed runs that have a real request payload', () => { + expect(component.canRetryRun({ + id: 'failed-run', + profileId: 'profile-1', + profileName: 'Daily VEX Export', + status: 'failed', + startedAt: '2026-04-15T10:00:00Z', + progress: 0, + itemsProcessed: 0, + itemsTotal: 0, + request: { + profileId: 'profile-1', + targets: ['vex'], + formats: ['json'], + }, + })).toBeTrue(); + + expect(component.canRetryRun({ + id: 'bundle-run', + profileId: 'stella-bundle', + profileName: 'StellaBundle Export', + status: 'failed', + startedAt: '2026-04-15T10:00:00Z', + progress: 0, + itemsProcessed: 0, + itemsTotal: 0, + })).toBeFalse(); + }); + + it('routes bundle details into the canonical bundles page', () => { + const result: StellaBundleExportResult = { + success: true, + bundleId: 'bundle-001', + exportId: 'stella-export-001', + artifactId: 'artifact-demo-123', + format: 'oci', + ociReference: 'oci://registry.example.com/audit@sha256:123', + checksumSha256: 'sha256:123', + sizeBytes: 1024, + includedFiles: ['bundle.json'], + durationMs: 500, + completedAt: new Date().toISOString(), + }; + + component.onViewBundleDetails(result); + + expect(mockRouter.navigate).toHaveBeenCalledWith(['/evidence/exports/bundles'], { + queryParams: { + search: result.bundleId, + artifactId: result.artifactId, + }, + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/tests/image_security/image-security-truthful-state.spec.ts b/src/Web/StellaOps.Web/src/tests/image_security/image-security-truthful-state.spec.ts new file mode 100644 index 000000000..1c6228283 --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/image_security/image-security-truthful-state.spec.ts @@ -0,0 +1,291 @@ +import { computed, signal, type Type } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; + +import type { ManagedRelease } from '../../app/core/api/release-management.models'; +import type { PlatformContextEnvironment } from '../../app/core/context/platform-context.store'; +import { PlatformContextStore } from '../../app/core/context/platform-context.store'; +import { ImageSecurityDataService, type ImageSecurityFinding } from '../../app/features/image-security/image-security-data.service'; +import { ImageScopeBarComponent } from '../../app/features/image-security/image-scope-bar.component'; +import { ImageSecurityScopeService } from '../../app/features/image-security/image-security-scope.service'; +import { ImageEvidenceTabComponent } from '../../app/features/image-security/tabs/image-evidence-tab.component'; +import { ImageFindingsTabComponent } from '../../app/features/image-security/tabs/image-findings-tab.component'; +import { ImageReachabilityTabComponent } from '../../app/features/image-security/tabs/image-reachability-tab.component'; +import { ImageSbomTabComponent } from '../../app/features/image-security/tabs/image-sbom-tab.component'; +import { ImageSummaryTabComponent } from '../../app/features/image-security/tabs/image-summary-tab.component'; +import { ImageVexTabComponent } from '../../app/features/image-security/tabs/image-vex-tab.component'; +import { ReleaseManagementStore } from '../../app/features/release-orchestrator/releases/release.store'; + +const RELEASE: ManagedRelease = { + id: 'rel-100', + name: 'checkout-api', + version: '2026.04.15', + description: 'Checkout release', + status: 'ready', + releaseType: 'standard', + slug: 'checkout-api', + digest: 'sha256:release', + currentStage: 'security', + currentEnvironment: 'env-prod-eu', + targetEnvironment: 'env-prod-eu', + targetRegion: 'eu', + componentCount: 2, + gateStatus: 'warn', + gateBlockingCount: 1, + gatePendingApprovals: 0, + gateBlockingReasons: ['reachable critical'], + riskCriticalReachable: 1, + riskHighReachable: 2, + riskTrend: 'stable', + riskTier: 'high', + evidencePosture: 'partial', + needsApproval: false, + blocked: false, + hotfixLane: false, + replayMismatch: false, + createdAt: '2026-04-15T08:00:00Z', + createdBy: 'qa', + updatedAt: '2026-04-15T10:00:00Z', + lastActor: 'qa', + deployedAt: null, + deploymentStrategy: 'rolling', +}; + +const FINDINGS: ImageSecurityFinding[] = [ + { + id: 'finding-100', + cveId: 'CVE-2099-0001', + severity: 'critical', + packageName: 'openssl', + componentName: 'openssl@3.0.8', + releaseId: RELEASE.id, + releaseName: RELEASE.version, + environment: 'env-prod-eu', + region: 'eu', + reachable: true, + reachabilityScore: 0.91, + vexStatus: 'affected', + updatedAt: '2026-04-15T09:55:00Z', + }, + { + id: 'finding-101', + cveId: 'CVE-2099-0002', + severity: 'medium', + packageName: 'curl', + componentName: 'curl@8.1.0', + releaseId: RELEASE.id, + releaseName: RELEASE.version, + environment: 'env-prod-eu', + region: 'eu', + reachable: null, + reachabilityScore: null, + vexStatus: 'under_investigation', + updatedAt: '2026-04-15T09:50:00Z', + }, +]; + +const SBOM_ROWS = [ + { + componentId: 'cmp-100', + releaseId: RELEASE.id, + releaseName: RELEASE.version, + environment: 'env-prod-eu', + region: 'eu', + packageName: 'openssl', + componentName: 'openssl', + componentVersion: '3.0.8', + supplier: 'OpenSSL', + license: 'Apache-2.0', + vulnerabilityCount: 1, + criticalReachableCount: 1, + updatedAt: '2026-04-15T09:40:00Z', + }, +]; + +const ENVIRONMENTS: PlatformContextEnvironment[] = [ + { + environmentId: 'env-prod-eu', + regionId: 'eu', + environmentType: 'production', + displayName: 'EU Production', + sortOrder: 1, + enabled: true, + }, +]; + +function createScopeServiceStub() { + return { + repository: signal(null), + imageRef: signal(null), + scanId: signal(null), + releaseId: signal(RELEASE.id), + environment: signal('env-prod-eu'), + }; +} + +function createDataServiceStub( + releaseValue: ManagedRelease | null = RELEASE, + findingsValue: ImageSecurityFinding[] = FINDINGS, + sbomValue: typeof SBOM_ROWS = SBOM_ROWS, +) { + const release = signal(releaseValue); + const findings = signal(findingsValue); + const sbomRows = signal(sbomValue); + const findingsLoading = signal(false); + const sbomLoading = signal(false); + const findingsError = signal(null); + const sbomError = signal(null); + const imageRefs = signal(['docker.io/acme/checkout-api:2026.04.15']); + + return { + release, + findings, + sbomRows, + findingsLoading, + sbomLoading, + findingsError, + sbomError, + imageRefs, + loading: computed(() => findingsLoading() || sbomLoading()), + inventoryScopeMessage: computed(() => (release() ? null : 'Select a release to load image security data.')), + selectedEnvironmentName: computed(() => 'EU Production'), + gateStatus: computed(() => release()?.gateStatus ?? 'unknown'), + riskTier: computed(() => release()?.riskTier ?? 'unknown'), + evidencePosture: computed(() => release()?.evidencePosture ?? 'unknown'), + latestFindingUpdate: computed(() => findings()[0]?.updatedAt ?? null), + }; +} + +function createReleaseStoreStub() { + return { + releases: signal([RELEASE]), + loading: signal(false), + selectRelease: jasmine.createSpy('selectRelease'), + clearSelection: jasmine.createSpy('clearSelection'), + }; +} + +function createPlatformContextStub() { + return { + initialize: jasmine.createSpy('initialize'), + environments: signal(ENVIRONMENTS), + }; +} + +async function render(component: Type, providers: object[]): Promise> { + await TestBed.configureTestingModule({ + imports: [component], + providers: [provideRouter([]), ...providers], + }).compileComponents(); + + const fixture = TestBed.createComponent(component); + fixture.detectChanges(); + return fixture; +} + +describe('Image security truthful state cutover', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + }); + + it('renders real release and environment options in the scope bar', async () => { + const fixture = await render(ImageScopeBarComponent, [ + { provide: ImageSecurityScopeService, useValue: createScopeServiceStub() }, + { provide: ImageSecurityDataService, useValue: createDataServiceStub() }, + { provide: ReleaseManagementStore, useValue: createReleaseStoreStub() }, + { provide: PlatformContextStore, useValue: createPlatformContextStub() }, + ]); + + const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; + expect(text).toContain('2026.04.15'); + expect(text).toContain('checkout-api'); + expect(text).toContain('EU Production'); + expect(text).toContain('docker.io/acme/checkout-api:2026.04.15'); + expect(text).not.toContain('Dev'); + expect(text).not.toContain('Staging'); + }); + + it('renders summary metrics from live release, findings, and sbom signals', async () => { + const fixture = await render(ImageSummaryTabComponent, [ + { provide: ImageSecurityDataService, useValue: createDataServiceStub() }, + ]); + + const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; + expect(text).toContain('Warn'); + expect(text).toContain('High'); + expect(text).toContain('Partial'); + expect(text).toContain('Reachability'); + expect(text).toContain('SBOM Inventory'); + expect(text).toContain('Open Scan Submission'); + expect(text).not.toContain('2 hours ago'); + expect(text).not.toContain('Scan Now'); + }); + + it('renders findings from the live findings service instead of seeded rows', async () => { + const fixture = await render(ImageFindingsTabComponent, [ + { provide: ImageSecurityDataService, useValue: createDataServiceStub() }, + ]); + + const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; + expect(text).toContain('CVE-2099-0001'); + expect(text).toContain('openssl'); + expect(text).not.toContain('CVE-2024-12345'); + }); + + it('shows metadata-only reachability evidence instead of fake call paths', async () => { + const fixture = await render(ImageReachabilityTabComponent, [ + { provide: ImageSecurityDataService, useValue: createDataServiceStub() }, + ]); + + const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; + expect(text).toContain('Call path unavailable in current contract'); + expect(text).toContain('CVE-2099-0001'); + expect(text).not.toContain('app.js'); + }); + + it('renders live sbom rows from the supply-chain contract', async () => { + const fixture = await render(ImageSbomTabComponent, [ + { provide: ImageSecurityDataService, useValue: createDataServiceStub() }, + ]); + + const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; + expect(text).toContain('OpenSSL'); + expect(text).toContain('Apache-2.0'); + expect(text).not.toContain('libunknown'); + }); + + it('shows metadata-only vex messaging instead of fake source and justification data', async () => { + const fixture = await render(ImageVexTabComponent, [ + { provide: ImageSecurityDataService, useValue: createDataServiceStub() }, + ]); + + const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; + expect(text).toContain('Justification text and source attribution are not available'); + expect(text).toContain('Metadata only'); + expect(text).not.toContain('Acme Corp'); + }); + + it('shows release-level evidence posture when image packet detail is unavailable', async () => { + const fixture = await render(ImageEvidenceTabComponent, [ + { provide: ImageSecurityDataService, useValue: createDataServiceStub() }, + ]); + + const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; + expect(text).toContain('Image-scoped evidence packets are not available'); + expect(text).toContain('2026.04.15'); + expect(text).toContain('checkout-api'); + expect(text).toContain('Open release evidence'); + expect(text).not.toContain('v2.3.1-rc2'); + }); + + it('renders an explicit empty state when no release scope is selected', async () => { + const fixture = await render(ImageSummaryTabComponent, [ + { provide: ImageSecurityDataService, useValue: createDataServiceStub(null, [], []) }, + ]); + + const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; + expect(text).toContain('Select a release to inspect image security'); + expect(text).toContain('Select a release to load image security data.'); + expect(text).not.toContain('Scan Now'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/tests/integration_hub/integration-activity.component.spec.ts b/src/Web/StellaOps.Web/src/tests/integration_hub/integration-activity.component.spec.ts new file mode 100644 index 000000000..f27edfcb4 --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/integration_hub/integration-activity.component.spec.ts @@ -0,0 +1,217 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { vi } from 'vitest'; + +import { AuditLogClient } from '../../app/core/api/audit-log.client'; +import { type AuditEvent, type AuditEventsPagedResponse } from '../../app/core/api/audit-log.models'; +import { + type IntegrationListResponse, + IntegrationProvider, + IntegrationStatus, + IntegrationType, + HealthStatus, +} from '../../app/features/integration-hub/integration.models'; +import { IntegrationService } from '../../app/features/integration-hub/integration.service'; +import { IntegrationActivityComponent } from '../../app/features/integration-hub/integration-activity.component'; + +describe('IntegrationActivityComponent', () => { + let component: IntegrationActivityComponent; + let fixture: ComponentFixture; + let auditLogClient: jasmine.SpyObj; + let integrationService: jasmine.SpyObj; + + const auditResponse: AuditEventsPagedResponse = { + items: [ + { + id: 'evt-1', + timestamp: '2026-04-15T10:00:00Z', + module: 'integrations', + action: 'create', + severity: 'info', + actor: { + id: 'user-1', + name: 'Admin User', + email: 'admin@example.com', + type: 'user', + }, + resource: { + type: 'integration', + id: 'int-1', + name: 'Production Harbor', + }, + description: 'Integration created.', + details: {}, + tags: [], + }, + { + id: 'evt-2', + timestamp: '2026-04-15T09:30:00Z', + module: 'integrations', + action: 'test', + severity: 'error', + actor: { + id: 'service-1', + name: 'Connection tester', + type: 'service', + }, + resource: { + type: 'integration', + id: 'int-2', + name: 'GitHub Enterprise', + }, + description: 'Connection test failed.', + details: {}, + tags: [], + }, + ] satisfies AuditEvent[], + cursor: 'cursor-2', + hasMore: true, + }; + + const integrationResponse: IntegrationListResponse = { + items: [ + { + id: 'int-1', + name: 'Production Harbor', + type: IntegrationType.Registry, + provider: IntegrationProvider.Harbor, + status: IntegrationStatus.Active, + endpoint: 'https://harbor.example.com', + hasAuth: true, + lastHealthStatus: HealthStatus.Healthy, + createdAt: '2026-04-01T00:00:00Z', + updatedAt: '2026-04-15T00:00:00Z', + tags: [], + }, + { + id: 'int-2', + name: 'GitHub Enterprise', + type: IntegrationType.Scm, + provider: IntegrationProvider.GitHubApp, + status: IntegrationStatus.Active, + endpoint: 'https://github.example.com', + hasAuth: true, + lastHealthStatus: HealthStatus.Degraded, + createdAt: '2026-04-01T00:00:00Z', + updatedAt: '2026-04-15T00:00:00Z', + tags: [], + }, + ], + totalCount: 2, + page: 1, + pageSize: 200, + totalPages: 1, + }; + + beforeEach(async () => { + auditLogClient = jasmine.createSpyObj('AuditLogClient', ['getEvents']); + integrationService = jasmine.createSpyObj('IntegrationService', ['list']); + + auditLogClient.getEvents.and.returnValue(of(auditResponse)); + integrationService.list.and.returnValue(of(integrationResponse)); + + await TestBed.configureTestingModule({ + imports: [IntegrationActivityComponent], + providers: [ + provideRouter([]), + { provide: AuditLogClient, useValue: auditLogClient }, + { provide: IntegrationService, useValue: integrationService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(IntegrationActivityComponent); + component = fixture.componentInstance; + }); + + it('loads activity events from audit APIs on init', () => { + fixture.detectChanges(); + + expect(integrationService.list).toHaveBeenCalled(); + expect(auditLogClient.getEvents).toHaveBeenCalled(); + expect(component.events.length).toBe(2); + expect(component.events[0].integrationProvider).toBe('Harbor'); + expect(component.events[1].type).toBe('test_failure'); + }); + + it('filters by event type and integration', () => { + fixture.detectChanges(); + + component.filterType = 'created'; + component.filterIntegration = 'int-1'; + component.applyFilters(); + + expect(component.filteredEvents.length).toBe(1); + expect(component.filteredEvents[0].id).toBe('evt-1'); + }); + + it('filters by date range', () => { + fixture.detectChanges(); + + component.filterFromDate = '2026-04-15'; + component.filterToDate = '2026-04-15'; + component.applyFilters(); + + expect(component.filteredEvents.length).toBe(2); + }); + + it('clears filters', () => { + fixture.detectChanges(); + + component.filterType = 'created'; + component.filterIntegration = 'int-1'; + component.filterFromDate = '2026-04-15'; + component.filterToDate = '2026-04-15'; + + component.clearFilters(); + + expect(component.filterType).toBe(''); + expect(component.filterIntegration).toBe(''); + expect(component.filterFromDate).toBe(''); + expect(component.filterToDate).toBe(''); + }); + + it('loads more events from the next cursor', () => { + auditLogClient.getEvents.and.returnValues( + of(auditResponse), + of({ + items: [ + { + ...auditResponse.items[0], + id: 'evt-3', + timestamp: '2026-04-15T09:00:00Z', + }, + ], + cursor: null, + hasMore: false, + }), + ); + + fixture.detectChanges(); + component.loadMore(); + + expect(component.events.map((event) => event.id)).toEqual(['evt-1', 'evt-2', 'evt-3']); + expect(component.hasMore).toBe(false); + }); + + it('refreshes automatically every 30 seconds', () => { + vi.useFakeTimers(); + fixture.detectChanges(); + + vi.advanceTimersByTime(30000); + + expect(auditLogClient.getEvents).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + + it('surfaces audit-loading errors without fake fallback data', () => { + auditLogClient.getEvents.and.returnValue(throwError(() => new Error('audit unavailable'))); + + fixture = TestBed.createComponent(IntegrationActivityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.events).toEqual([]); + expect(component.errorMessage).toBe('audit unavailable'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/tests/issuer_trust/issuer-trust-management-ui.component.spec.ts b/src/Web/StellaOps.Web/src/tests/issuer_trust/issuer-trust-management-ui.component.spec.ts index 78e62a4c2..f5b12c017 100644 --- a/src/Web/StellaOps.Web/src/tests/issuer_trust/issuer-trust-management-ui.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/issuer_trust/issuer-trust-management-ui.component.spec.ts @@ -38,7 +38,100 @@ describe('Issuer Trust Management UI (issuer_trust)', () => { it('filters issuer list by status and search query', async () => { await TestBed.configureTestingModule({ imports: [IssuerListComponent], - providers: [provideRouter([])], + providers: [ + provideRouter([]), + { + provide: TRUST_API, + useValue: { + listIssuers: () => of({ + items: [ + { + issuerId: 'issuer-001', + tenantId: 'tenant-a', + name: 'stellaops-root', + displayName: 'StellaOps Root', + description: 'Primary trust root', + issuerType: 'attestation_authority', + trustLevel: 'full', + trustScore: 95, + publicKeyFingerprints: ['SHA256:abc'], + validFrom: '2026-01-01T00:00:00Z', + validUntil: '2027-01-01T00:00:00Z', + verificationCount: 10, + documentCount: 20, + weights: { + baseWeight: 50, + recencyFactor: 10, + verificationBonus: 20, + volumePenalty: 5, + manualAdjustment: 0, + }, + metadata: {}, + isActive: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-04-15T00:00:00Z', + }, + { + issuerId: 'issuer-002', + tenantId: 'tenant-a', + name: 'sigstore', + displayName: 'Sigstore', + description: 'Keyless issuer', + issuerType: 'csaf_publisher', + trustLevel: 'full', + trustScore: 92, + publicKeyFingerprints: ['SHA256:def'], + validFrom: '2026-01-01T00:00:00Z', + validUntil: '2027-01-01T00:00:00Z', + verificationCount: 8, + documentCount: 18, + weights: { + baseWeight: 50, + recencyFactor: 10, + verificationBonus: 20, + volumePenalty: 5, + manualAdjustment: 0, + }, + metadata: {}, + isActive: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-04-15T00:00:00Z', + }, + { + issuerId: 'issuer-003', + tenantId: 'tenant-a', + name: 'legacy', + displayName: 'Legacy Issuer', + description: 'Blocked legacy issuer', + issuerType: 'attestation_authority', + trustLevel: 'blocked', + trustScore: 0, + publicKeyFingerprints: ['SHA256:ghi'], + validFrom: '2025-01-01T00:00:00Z', + validUntil: '2025-12-31T00:00:00Z', + verificationCount: 1, + documentCount: 2, + weights: { + baseWeight: 50, + recencyFactor: 10, + verificationBonus: 20, + volumePenalty: 5, + manualAdjustment: 0, + }, + metadata: {}, + isActive: false, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-12-31T00:00:00Z', + }, + ], + pageNumber: 1, + pageSize: 200, + totalCount: 3, + totalPages: 1, + }), + }, + }, + ], }).compileComponents(); const fixture = TestBed.createComponent(IssuerListComponent); @@ -56,11 +149,46 @@ describe('Issuer Trust Management UI (issuer_trust)', () => { expect(filtered[0].id).toBe('issuer-002'); }); - it('loads issuer detail for route id and supports revoke lifecycle action', async () => { + it('loads issuer detail for the route id from TRUST_API', async () => { await TestBed.configureTestingModule({ imports: [IssuerDetailComponent], providers: [ provideRouter([]), + { + provide: TRUST_API, + useValue: { + getIssuer: () => of({ + issuerId: 'issuer-999', + tenantId: 'tenant-a', + name: 'detail-issuer', + displayName: 'Detail Issuer', + description: 'Detail view issuer', + issuerType: 'csaf_publisher', + trustLevel: 'full', + trustScore: 90, + publicKeyFingerprints: ['SHA256:detail'], + validFrom: '2026-01-01T00:00:00Z', + validUntil: '2027-01-01T00:00:00Z', + lastVerifiedAt: '2026-04-15T10:00:00Z', + verificationCount: 11, + documentCount: 21, + url: 'https://issuer.example.test', + weights: { + baseWeight: 50, + recencyFactor: 10, + verificationBonus: 20, + volumePenalty: 5, + manualAdjustment: 0, + }, + metadata: { + region: 'eu', + }, + isActive: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-04-15T00:00:00Z', + }), + }, + }, { provide: ActivatedRoute, useValue: { @@ -77,12 +205,8 @@ describe('Issuer Trust Management UI (issuer_trust)', () => { fixture.detectChanges(); expect(component.issuer()?.id).toBe('issuer-999'); - - spyOn(window, 'confirm').and.returnValue(true); - component.revokeIssuer(); - - expect(component.issuer()?.status).toBe('revoked'); - expect(component.issuer()?.trustLevel).toBe('low'); + expect(component.issuer()?.publicKeyFingerprints.length).toBe(1); + expect(component.issuer()?.metadata[0]).toEqual({ key: 'region', value: 'eu' }); }); it('submits issuer editor and navigates back to list', async () => { diff --git a/src/Web/StellaOps.Web/src/tests/lineage/node-diff-table-component.spec.ts b/src/Web/StellaOps.Web/src/tests/lineage/node-diff-table-component.spec.ts deleted file mode 100644 index 985483f0b..000000000 --- a/src/Web/StellaOps.Web/src/tests/lineage/node-diff-table-component.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { of } from 'rxjs'; - -import type { DiffTableRow } from '../../app/features/lineage/components/node-diff-table/models/diff-table.models'; -import { NodeDiffTableComponent } from '../../app/features/lineage/components/node-diff-table/diff-table.component'; -import { LineageGraphService } from '../../app/features/lineage/services/lineage-graph.service'; - -describe('Node Diff Table Component (lineage)', () => { - let fixture: ComponentFixture; - let component: NodeDiffTableComponent; - let getDiffSpy: jasmine.Spy; - - const rows: DiffTableRow[] = [ - { - id: 'pkg-a', - name: 'openssl', - purl: 'pkg:generic/openssl@3.0.0', - changeType: 'added', - currentVersion: '3.0.0', - currentLicense: 'Apache-2.0', - expanded: false, - selected: false, - vulnImpact: { resolved: [], introduced: ['CVE-1'], unchanged: [] }, - }, - { - id: 'pkg-b', - name: 'zlib', - purl: 'pkg:generic/zlib@1.2.13', - changeType: 'removed', - previousVersion: '1.2.11', - previousLicense: 'Zlib', - expanded: false, - selected: false, - vulnImpact: { resolved: ['CVE-2'], introduced: [], unchanged: [] }, - }, - { - id: 'pkg-c', - name: 'nghttp2', - purl: 'pkg:generic/nghttp2@1.58.0', - changeType: 'both-changed', - previousVersion: '1.55.0', - currentVersion: '1.58.0', - previousLicense: 'Apache-2.0', - currentLicense: 'MIT', - expanded: false, - selected: false, - vulnImpact: { resolved: [], introduced: [], unchanged: ['CVE-3'] }, - }, - ]; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [NodeDiffTableComponent], - providers: [ - { - provide: LineageGraphService, - useValue: { - getDiff: (getDiffSpy = jasmine.createSpy('getDiff').and.returnValue( - of({ - componentDiff: { - added: [], - removed: [], - changed: [], - }, - }) - )), - }, - }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(NodeDiffTableComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('rows', rows); - fixture.detectChanges(); - }); - - it('computes stats and paginates deterministically', () => { - expect(component.stats().total).toBe(3); - expect(component.stats().added).toBe(1); - expect(component.stats().removed).toBe(1); - expect(component.stats().changed).toBe(1); - expect(component.stats().vulnerable).toBe(2); - - component.pageSize.set(2); - component.currentPage.set(2); - expect(component.paginatedRows().map((row) => row.id)).toEqual(['pkg-b']); - }); - - it('supports debounced search, sorting, and bulk selection', async () => { - component.onSearchInput('openssl'); - await new Promise((resolve) => setTimeout(resolve, 350)); - fixture.detectChanges(); - - expect(component.filterState().searchTerm).toBe('openssl'); - expect(component.displayedRows().map((row) => row.id)).toEqual(['pkg-a']); - - component.toggleSort('name'); - expect(component.sortState().column).toBe('name'); - expect(component.sortState().direction).toBe('desc'); - - component.clearFilters(); - component.toggleSelectAll(); - expect(component.selectedRowIds().size).toBe(3); - component.toggleSelectAll(); - expect(component.selectedRowIds().size).toBe(0); - }); - - it('toggles both-changed filter chips deterministically', () => { - component.clearFilters(); - component.toggleChangeTypeFilter('both-changed'); - - expect(component.filterState().changeTypes.has('both-changed')).toBeTrue(); - expect(component.displayedRows().map((row) => row.id)).toEqual(['pkg-c']); - - component.toggleChangeTypeFilter('both-changed'); - expect(component.filterState().changeTypes.size).toBe(0); - }); - - it('fetches API diff exactly once per unique digest+tenant combination', () => { - fixture.componentRef.setInput('fromDigest', 'sha256:from-a'); - fixture.componentRef.setInput('toDigest', 'sha256:to-a'); - fixture.componentRef.setInput('tenantId', 'tenant-alpha'); - fixture.detectChanges(); - - expect(getDiffSpy).toHaveBeenCalledTimes(1); - expect(getDiffSpy).toHaveBeenCalledWith('sha256:from-a', 'sha256:to-a', 'tenant-alpha'); - - fixture.componentRef.setInput('tenantId', 'tenant-alpha'); - fixture.detectChanges(); - expect(getDiffSpy).toHaveBeenCalledTimes(1); - - fixture.componentRef.setInput('toDigest', 'sha256:to-b'); - fixture.detectChanges(); - expect(getDiffSpy).toHaveBeenCalledTimes(2); - expect(getDiffSpy).toHaveBeenCalledWith('sha256:from-a', 'sha256:to-b', 'tenant-alpha'); - }); - - it('stops applying debounced search updates after component destruction', async () => { - fixture.destroy(); - - component.onSearchInput('nghttp2'); - await new Promise((resolve) => setTimeout(resolve, 350)); - - expect(component.filterState().searchTerm).toBe(''); - }); -}); diff --git a/src/Web/StellaOps.Web/src/tests/offline/offline-kit-ui-integration.spec.ts b/src/Web/StellaOps.Web/src/tests/offline/offline-kit-ui-integration.spec.ts index 56e6f5046..1c9521256 100644 --- a/src/Web/StellaOps.Web/src/tests/offline/offline-kit-ui-integration.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/offline/offline-kit-ui-integration.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { signal } from '@angular/core'; -import { BundleValidationResult, OfflineManifest } from '../../app/core/api/offline-kit.models'; +import { OfflineManifest } from '../../app/core/api/offline-kit.models'; import { OfflineModeService } from '../../app/core/services/offline-mode.service'; import { BundleManagementComponent } from '../../app/features/offline-kit/components/bundle-management.component'; import { offlineKitRoutes } from '../../app/features/offline-kit/offline-kit.routes'; @@ -14,7 +14,12 @@ class OfflineModeServiceStub { enterOfflineMode = jasmine.createSpy('enterOfflineMode'); exitOfflineMode = jasmine.createSpy('exitOfflineMode').and.returnValue(Promise.resolve(true)); - loadManifest = jasmine.createSpy('loadManifest'); + loadManifest = jasmine.createSpy('loadManifest').and.callFake((manifest: OfflineManifest) => { + this.cachedManifest.set(manifest); + }); + clearManifest = jasmine.createSpy('clearManifest').and.callFake(() => { + this.cachedManifest.set(null); + }); } describe('Offline Kit UI Integration (B24-001)', () => { @@ -22,76 +27,100 @@ describe('Offline Kit UI Integration (B24-001)', () => { let component: BundleManagementComponent; let offlineStub: OfflineModeServiceStub; + const cachedManifest: OfflineManifest = { + version: '2026.04.15', + createdAt: '2026-04-15T10:00:00Z', + expiresAt: '2026-05-15T10:00:00Z', + signature: 'sig:cached:2026.04.15', + assets: { + ui: { 'index.html': 'sha256:index' }, + api_contracts: { 'policy.openapi.json': 'sha256:policy' }, + authority: { 'jwks.json': 'sha256:jwks' }, + feeds: { 'advisory_snapshot.ndjson.gz': 'sha256:feed' }, + }, + }; + beforeEach(async () => { + localStorage.clear(); offlineStub = new OfflineModeServiceStub(); await TestBed.configureTestingModule({ imports: [BundleManagementComponent], providers: [{ provide: OfflineModeService, useValue: offlineStub }], }).compileComponents(); + }); + function createComponent(): void { fixture = TestBed.createComponent(BundleManagementComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } - it('keeps activate action on non-active bundles and switches active bundle on selection', () => { - const nonActiveBundle = component.loadedBundles().find((bundle) => bundle.id === 'bundle-002'); - expect(nonActiveBundle).toBeTruthy(); + it('renders an empty state on first load when no bundle has been cached', () => { + createComponent(); + + expect(component.loadedBundles()).toEqual([]); const compiled = fixture.nativeElement as HTMLElement; - const nonActiveCard = Array.from(compiled.querySelectorAll('.bundle-card')).find((card) => - card.textContent?.includes('v2025.01.08') - ) as HTMLElement | undefined; - expect(nonActiveCard).toBeTruthy(); + expect(compiled.textContent).toContain('No bundles loaded. Upload a manifest to get started.'); + expect(compiled.textContent).not.toContain('v2025.01.15'); + }); - const activateButton = Array.from(nonActiveCard!.querySelectorAll('button')).find((button) => - button.textContent?.trim().includes('Set Active') - ) as HTMLButtonElement | undefined; - expect(activateButton).toBeTruthy(); + it('hydrates the bundle list from the cached manifest instead of seeded example rows', () => { + offlineStub.cachedManifest.set(cachedManifest); - activateButton!.dispatchEvent(new MouseEvent('click')); + createComponent(); + + expect(component.loadedBundles().length).toBe(1); + expect(component.loadedBundles()[0].version).toBe('2026.04.15'); + expect(component.assetCategories().map((category) => category.name)).toEqual([ + 'UI Assets', + 'API Contracts', + 'Authority', + 'Feed Data', + ]); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('v2026.04.15'); + expect(compiled.textContent).not.toContain('v2025.01.08'); + }); + + it('loads a validated manifest into browser state when a real payload is supplied', () => { + createComponent(); + + component.onManifestValidated({ + fileName: 'offline-manifest.json', + fileSize: 2048, + manifest: cachedManifest, + result: { + valid: true, + errors: [], + warnings: [], + assetIntegrity: { + totalAssets: 4, + validAssets: 4, + invalidAssets: 0, + missingAssets: [], + hashMismatches: [], + }, + signatureStatus: { + valid: true, + algorithm: 'ES256', + }, + }, + }); fixture.detectChanges(); - expect(component.loadedBundles().some((bundle) => bundle.id === 'bundle-002' && bundle.status === 'active')).toBeTrue(); - expect(component.loadedBundles().some((bundle) => bundle.id === 'bundle-001' && bundle.status === 'active')).toBeFalse(); - }); - - it('defines offline-kit nested routes for dashboard, bundles, verify, and jwks', () => { - const root = offlineKitRoutes.find((route) => route.path === ''); - expect(root).toBeDefined(); - expect(root?.children?.some((child) => child.path === 'dashboard')).toBeTrue(); - expect(root?.children?.some((child) => child.path === 'bundles')).toBeTrue(); - expect(root?.children?.some((child) => child.path === 'verify')).toBeTrue(); - expect(root?.children?.some((child) => child.path === 'jwks')).toBeTrue(); - }); - - it('loads the generated manifest into offline state when a valid bundle is added', () => { - const result: BundleValidationResult = { - valid: true, - errors: [], - warnings: [], - assetIntegrity: { - totalAssets: 6, - validAssets: 6, - invalidAssets: 0, - missingAssets: [], - hashMismatches: [], - }, - signatureStatus: { - valid: true, - algorithm: 'ES256', - }, - }; - - component.onManifestValidated(result); - - expect(offlineStub.loadManifest).toHaveBeenCalled(); + expect(offlineStub.loadManifest).toHaveBeenCalledWith(cachedManifest); expect(component.activeBundleId()).not.toBeNull(); - expect(component.loadedBundles()[0].status).toBe('active'); + expect(component.loadedBundles()[0].assetCount).toBe(4); + expect(component.lastExportMessage()).toContain('Loaded bundle v2026.04.15'); }); it('exports bundle summaries through a real download flow', () => { + offlineStub.cachedManifest.set(cachedManifest); + createComponent(); + const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:test'); const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL'); const anchorClickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.stub(); @@ -104,4 +133,13 @@ describe('Offline Kit UI Integration (B24-001)', () => { expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:test'); expect(component.lastExportMessage()).toContain(`v${bundle.version}`); }); + + it('defines offline-kit nested routes for dashboard, bundles, verify, and jwks', () => { + const root = offlineKitRoutes.find((route) => route.path === ''); + expect(root).toBeDefined(); + expect(root?.children?.some((child) => child.path === 'dashboard')).toBeTrue(); + expect(root?.children?.some((child) => child.path === 'bundles')).toBeTrue(); + expect(root?.children?.some((child) => child.path === 'verify')).toBeTrue(); + expect(root?.children?.some((child) => child.path === 'jwks')).toBeTrue(); + }); }); diff --git a/src/Web/StellaOps.Web/src/tests/release-control/release-control-routes.spec.ts b/src/Web/StellaOps.Web/src/tests/release-control/release-control-routes.spec.ts index 7b7161281..6f60fb959 100644 --- a/src/Web/StellaOps.Web/src/tests/release-control/release-control-routes.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/release-control/release-control-routes.spec.ts @@ -1,3 +1,6 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter, Router } from '@angular/router'; + import { RELEASE_CONTROL_ROUTES } from '../../app/routes/release-control.routes'; import { RELEASES_ROUTES } from '../../app/routes/releases.routes'; import { APPROVALS_ROUTES } from '../../app/features/approvals/approvals.routes'; @@ -9,6 +12,12 @@ describe('legacy release-control routes (pre-alpha)', () => { }); describe('RELEASES_ROUTES (pre-alpha)', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideRouter([])], + }); + }); + it('contains canonical releases surfaces', () => { const paths = RELEASES_ROUTES.map((route) => route.path); const expected = [ @@ -70,6 +79,26 @@ describe('RELEASES_ROUTES (pre-alpha)', () => { 'readiness', ]); }); + + it('redirects deployments/new to the canonical promotions create flow', () => { + const route = RELEASES_ROUTES.find((entry) => entry.path === 'deployments/new'); + expect(typeof route?.redirectTo).toBe('function'); + + const urlTree = TestBed.runInInjectionContext(() => + (route!.redirectTo as (args: { + params: Record; + queryParams: Record; + fragment?: string | null; + }) => ReturnType)({ + params: {}, + queryParams: { releaseId: 'rel-123', source: 'detail' }, + fragment: 'compose', + }), + ); + + const router = TestBed.inject(Router); + expect(router.serializeUrl(urlTree)).toBe('/releases/promotions/create?releaseId=rel-123&source=detail#compose'); + }); }); describe('APPROVALS_ROUTES', () => { diff --git a/src/Web/StellaOps.Web/src/tests/topology/environments-command.component.spec.ts b/src/Web/StellaOps.Web/src/tests/topology/environments-command.component.spec.ts new file mode 100644 index 000000000..0f533c364 --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/topology/environments-command.component.spec.ts @@ -0,0 +1,104 @@ +import { signal } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; + +import { PlatformContextStore } from '../../app/core/context/platform-context.store'; +import { EnvironmentsCommandComponent } from '../../app/features/topology/environments-command.component'; + +describe('EnvironmentsCommandComponent', () => { + let httpMock: HttpTestingController; + let routeStub: { snapshot: { queryParamMap: ReturnType } }; + + const contextStub = { + initialize: jasmine.createSpy('initialize'), + selectedRegions: signal([]), + selectedEnvironments: signal([]), + regions: signal([ + { regionId: 'eu-west', displayName: 'EU West', sortOrder: 0, enabled: true }, + { regionId: 'us-east', displayName: 'US East', sortOrder: 1, enabled: true }, + ]), + }; + + beforeEach(async () => { + routeStub = { snapshot: { queryParamMap: convertToParamMap({}) } }; + + await TestBed.configureTestingModule({ + imports: [EnvironmentsCommandComponent], + providers: [ + provideRouter([]), + provideHttpClient(), + provideHttpClientTesting(), + { provide: PlatformContextStore, useValue: contextStub }, + { provide: ActivatedRoute, useValue: routeStub }, + ], + }).compileComponents(); + + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('renders truthful empty command state when topology has no environments', () => { + const fixture = TestBed.createComponent(EnvironmentsCommandComponent); + fixture.detectChanges(); + + httpMock.expectOne('/api/v2/topology/environments').flush({ items: [] }); + httpMock.expectOne('/api/v2/topology/layout').flush({ + nodes: [], + edges: [], + metadata: { + regionCount: 0, + environmentCount: 0, + promotionPathCount: 0, + canvasWidth: 0, + canvasHeight: 0, + }, + }); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('No environments are currently registered in topology.'); + expect(compiled.textContent).not.toContain('eu-prod-app-01'); + expect(compiled.textContent).not.toContain('apac-prod-app-01'); + }); + + it('shows the topology empty state instead of a generated fallback layout', () => { + routeStub.snapshot.queryParamMap = convertToParamMap({ view: 'topology' }); + + const fixture = TestBed.createComponent(EnvironmentsCommandComponent); + fixture.detectChanges(); + + httpMock.expectOne('/api/v2/topology/environments').flush({ + items: [ + { + environmentId: 'env-empty', + displayName: 'Empty Environment', + regionId: 'eu-west', + environmentType: 'staging', + }, + ], + }); + httpMock.expectOne('/api/v1/environments/env-empty/readiness').flush({ items: [] }); + httpMock.expectOne('/api/v2/topology/layout').flush({ + nodes: [], + edges: [], + metadata: { + regionCount: 1, + environmentCount: 1, + promotionPathCount: 0, + canvasWidth: 0, + canvasHeight: 0, + }, + }); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.textContent).toContain('No topology layout is available for the current scope.'); + expect(compiled.textContent).not.toContain('eu-stage'); + expect(compiled.textContent).not.toContain('us-prod'); + }); +}); diff --git a/src/Web/StellaOps.Web/tests/e2e/ui-truthful-state-cutover.recheck.spec.ts b/src/Web/StellaOps.Web/tests/e2e/ui-truthful-state-cutover.recheck.spec.ts new file mode 100644 index 000000000..5b9fc422a --- /dev/null +++ b/src/Web/StellaOps.Web/tests/e2e/ui-truthful-state-cutover.recheck.spec.ts @@ -0,0 +1,95 @@ +import type { Page } from '@playwright/test'; + +import { expect, test } from './integrations/live-auth.fixture'; + +const BASE_URL = process.env['PLAYWRIGHT_BASE_URL'] || 'https://stella-ops.local'; + +async function gotoRoute(page: Page, route: string): Promise { + await page.goto(`${BASE_URL}${route}`, { + waitUntil: 'domcontentloaded', + timeout: 45_000, + }); +} + +async function expectBodyNotToContain(page: Page, forbiddenText: readonly string[]): Promise { + const bodyText = await page.locator('body').innerText(); + for (const item of forbiddenText) { + expect(bodyText).not.toContain(item); + } +} + +test.describe.configure({ mode: 'serial' }); + +test('rechecks mounted truthful-state routes after the mock-data cutover', async ({ liveAuthPage: page }) => { + await gotoRoute(page, '/setup/integrations/activity'); + await expect(page.getByRole('heading', { name: 'Integration Activity' })).toBeVisible(); + await expect(page.getByText('Audit trail for all integration lifecycle events')).toBeVisible(); + await expectBodyNotToContain(page, ['Mock data for development']); + + await gotoRoute(page, '/evidence/exports?tab=profiles'); + await expect(page.getByRole('heading', { name: 'Export Center' })).toBeVisible(); + await expect(page.getByText('Profile creation, editing, and deletion are not exposed by the current Export Center API.')).toBeVisible(); + await expectBodyNotToContain(page, ['Mock data']); + + await gotoRoute(page, '/evidence/exports?tab=runs'); + await expect(page.getByText('Showing runs started from this browser session and completed StellaBundle exports.')).toBeVisible(); + + await gotoRoute(page, '/ops/operations/offline-kit/bundles'); + await expect(page.getByRole('heading', { name: 'Bundle Management' })).toBeVisible(); + await expect(page.getByText('Load, verify, and manage offline bundles for air-gapped operation')).toBeVisible(); + await expectBodyNotToContain(page, ['Mock data - in production, load from IndexedDB or cache']); + + await gotoRoute(page, '/setup/topology/overview'); + await expect(page.getByRole('radio', { name: 'Command' })).toBeVisible(); + await expect(page.getByRole('radio', { name: 'Topology' })).toBeVisible(); + await expect(page.getByRole('button', { name: /Validate All/i })).toBeVisible(); + await expectBodyNotToContain(page, ['Mock topology layout for when API returns empty']); + + await gotoRoute(page, '/setup/configuration-pane'); + await expect(page.getByRole('heading', { name: 'Configuration' })).toBeVisible(); + await expect(page.getByText('Manage platform integrations and settings')).toBeVisible(); + await expectBodyNotToContain(page, ['Primary Database']); + + await gotoRoute(page, '/security/images'); + await expect(page.getByRole('heading', { name: 'Image Security', exact: true })).toBeVisible(); + await expect(page.getByLabel('Release', { exact: true })).toBeVisible(); + await expect(page.getByLabel('Environment', { exact: true })).toBeVisible(); + await expect(page.getByText('No image security scope selected')).toBeVisible(); + await expect(page.getByText('Select a release to load real release components and image references.')).toBeVisible(); + await page.getByLabel('Release', { exact: true }).selectOption({ index: 1 }); + await expect(page.getByText('Release images')).toBeVisible(); + + await page.getByRole('tab', { name: 'VEX' }).click(); + await expect(page.getByText('The current findings contract exposes VEX status only.')).toBeVisible(); + await expectBodyNotToContain(page, ['Mock data']); + + await page.getByRole('tab', { name: 'Evidence' }).click(); + await expect(page.getByText('The mounted image-security route has access to release-level evidence posture only.')).toBeVisible(); +}); + +test('rechecks policy governance conflicts without fabricated preview content', async ({ liveAuthPage: page }) => { + await gotoRoute(page, '/ops/policy/governance/conflicts'); + await expect(page.getByRole('heading', { name: 'Policy Governance' })).toBeVisible(); + + await page.waitForFunction(() => { + const text = document.body?.innerText || ''; + return text.includes('No conflicts found') || text.includes('Quick Resolve') || text.includes('Conflict Resolution Wizard'); + }, null, { timeout: 20_000 }); + + const bodyText = await page.locator('body').innerText(); + if (bodyText.includes('No conflicts found')) { + await expect(page.getByText('No conflicts found')).toBeVisible(); + return; + } + + const resolveLinks = page.getByRole('link', { name: /Resolve/i }); + await expect(resolveLinks.first()).toBeVisible(); + await resolveLinks.first().click(); + + await expect(page.getByRole('heading', { name: 'Conflict Resolution Wizard' })).toBeVisible(); + await page.locator('.wizard-nav').getByRole('button', { name: /^Next$/ }).click(); + await expect(page.getByRole('heading', { name: 'Compare Conflicting Sources' })).toBeVisible(); + await expect(page.getByText('Full rule and policy bodies are not exposed by the current governance conflict API.')).toBeVisible(); + await expect(page.getByText('Full source content is unavailable here. The governance conflicts contract currently exposes source metadata only.').first()).toBeVisible(); + await expectBodyNotToContain(page, ['"condition":', '"action":']); +});