From 75ccdf81c1ad8c24c79e68087410fe1cbca0b64a Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 14 Apr 2026 21:44:35 +0300 Subject: [PATCH] Make local UI setup truthful and rerunnable --- devops/compose/README.md | 12 +- .../docker-compose.stella-ops.legacy.yml | 27 +- .../docker-compose.stella-services.yml | 13 + .../14-platform-environment-settings.sql | 21 +- docs/INSTALL_GUIDE.md | 9 + ...latform_ui_only_setup_bootstrap_closure.md | 3 + docs/integrations/LOCAL_SERVICES.md | 4 + docs/modules/platform/platform-service.md | 5 + docs/setup/setup-wizard-ux.md | 8 + .../StellaOps.Platform.WebService/Program.cs | 13 +- .../PostgresEnvironmentSettingsStore.cs | 3 +- ...ntSettingsInstallationScopeConvergence.sql | 72 +++++ ...ronmentSettingsPrimaryKeyNormalization.sql | 21 ++ ...entSettingsLegacySchemaIntegrationTests.cs | 273 ++++++++++++++++++ ...EnvironmentSettingsMigrationScriptTests.cs | 110 +++++++ .../SetupEndpointsTests.cs | 76 ++++- .../Middleware/RouteDispatchMiddleware.cs | 97 +++++-- ...outeDispatchMiddlewareReverseProxyTests.cs | 110 +++++++ .../scripts/live-frontdoor-auth.mjs | 120 ++++++-- .../live-setup-wizard-full-bootstrap.mjs | 11 +- .../components/setup-wizard.component.spec.ts | 86 ++++-- .../components/setup-wizard.component.ts | 68 +++-- .../components/step-content.component.spec.ts | 1 + .../components/step-content.component.ts | 123 +++++--- .../components/step-content.defaults.spec.ts | 36 +++ .../setup-wizard-state.service.spec.ts | 65 ++++- .../services/setup-wizard-state.service.ts | 56 +++- .../tsconfig.spec.active-surfaces.json | 2 + 28 files changed, 1272 insertions(+), 173 deletions(-) create mode 100644 src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/064_EnvironmentSettingsInstallationScopeConvergence.sql create mode 100644 src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/065_EnvironmentSettingsPrimaryKeyNormalization.sql create mode 100644 src/Platform/__Tests/StellaOps.Platform.WebService.Tests/EnvironmentSettingsLegacySchemaIntegrationTests.cs create mode 100644 src/Platform/__Tests/StellaOps.Platform.WebService.Tests/EnvironmentSettingsMigrationScriptTests.cs create mode 100644 src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareReverseProxyTests.cs create mode 100644 src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.defaults.spec.ts diff --git a/devops/compose/README.md b/devops/compose/README.md index f7c846fce..5a162312b 100644 --- a/devops/compose/README.md +++ b/devops/compose/README.md @@ -115,6 +115,13 @@ This sequence is the canonical migration gate for on-prem upgradeable deployment Current behavior details: - `./postgres-init` scripts execute only during first PostgreSQL initialization (`/docker-entrypoint-initdb.d` mount). +- `postgres-init/14-platform-environment-settings.sql` now creates the + canonical installation-scoped `platform.environment_settings` table without + pre-seeding `SetupComplete=true`; fresh local databases therefore enter the + setup wizard until the Platform setup session is finalized. Existing local + volumes that still carry the legacy `(tenant_id, key)` table shape are + auto-converged by Platform release migration + `064_EnvironmentSettingsInstallationScopeConvergence.sql`. - Some services run startup migrations via hosted services; others are currently CLI-only or not wired yet. - Use `docs/db/MIGRATION_INVENTORY.md` as the authoritative current-state matrix before production upgrades. - Consolidation target policy and module cutover waves are defined in `docs/db/MIGRATION_CONSOLIDATION_PLAN.md`. @@ -331,7 +338,10 @@ The harness now supports inline GitLab secret staging through the browser when `STELLAOPS_UI_BOOTSTRAP_GITLAB_REGISTRY_BASIC` are supplied. The separate first-run setup wizard now reaches the Platform setup API through the frontdoor and uses persisted installation-scoped setup sessions for the five -truthful control-plane steps. +truthful control-plane steps. The local compose lane also forwards +`AUTHORITY_BOOTSTRAP_APIKEY` into Platform as `STELLAOPS_BOOTSTRAP_KEY` so the +wizard can call Authority's `/internal/users` bootstrap endpoint during the +Admin step. **Hosts file entries** (add to `C:\Windows\System32\drivers\etc\hosts`): ``` diff --git a/devops/compose/docker-compose.stella-ops.legacy.yml b/devops/compose/docker-compose.stella-ops.legacy.yml index 9067045b6..57b2ee0e7 100644 --- a/devops/compose/docker-compose.stella-ops.legacy.yml +++ b/devops/compose/docker-compose.stella-ops.legacy.yml @@ -464,6 +464,7 @@ services: STELLAOPS_SIGNALS_URL: "http://signals.stella-ops.local" STELLAOPS_ADVISORYAI_URL: "http://advisoryai.stella-ops.local" STELLAOPS_UNKNOWNS_URL: "http://unknowns.stella-ops.local" + STELLAOPS_BOOTSTRAP_KEY: "${AUTHORITY_BOOTSTRAP_APIKEY:-stellaops-dev-bootstrap-key}" Router__Enabled: "${PLATFORM_ROUTER_ENABLED:-true}" Router__Messaging__ConsumerGroup: "platform" volumes: @@ -965,6 +966,9 @@ services: ConnectionStrings__Redis: "cache.stella-ops.local:6379" Postgres__ConnectionString: *postgres-connection Postgres__SchemaName: "vexhub" + Authority__ResourceServer__Authority: "https://authority.stella-ops.local/" + Authority__ResourceServer__MetadataAddress: "https://authority.stella-ops.local/.well-known/openid-configuration" + Authority__ResourceServer__RequireHttpsMetadata: "false" Router__Enabled: "${VEXHUB_ROUTER_ENABLED:-true}" Router__Messaging__ConsumerGroup: "vexhub" volumes: @@ -988,13 +992,22 @@ services: container_name: stellaops-vexlens-web restart: unless-stopped depends_on: *depends-infra - environment: - ASPNETCORE_URLS: "http://+:8080" - <<: [*kestrel-cert, *router-microservice-defaults, *gc-light] - ConnectionStrings__Default: *postgres-connection - ConnectionStrings__Redis: "cache.stella-ops.local:6379" - Router__Enabled: "${VEXLENS_ROUTER_ENABLED:-true}" - Router__Messaging__ConsumerGroup: "vexlens" + environment: + ASPNETCORE_URLS: "http://+:8080" + <<: [*kestrel-cert, *router-microservice-defaults, *gc-light] + ConnectionStrings__Default: *postgres-connection + ConnectionStrings__Redis: "cache.stella-ops.local:6379" + Authority__ResourceServer__Authority: "https://authority.stella-ops.local/" + Authority__ResourceServer__MetadataAddress: "https://authority.stella-ops.local/.well-known/openid-configuration" + Authority__ResourceServer__RequireHttpsMetadata: "false" + Authority__ResourceServer__Audiences__0: "" + Authority__ResourceServer__BypassNetworks__0: "172.19.0.0/16" + Authority__ResourceServer__BypassNetworks__1: "127.0.0.1/32" + Authority__ResourceServer__BypassNetworks__2: "::1/128" + Authority__ResourceServer__BypassNetworks__3: "0.0.0.0/0" + Authority__ResourceServer__BypassNetworks__4: "::/0" + Router__Enabled: "${VEXLENS_ROUTER_ENABLED:-true}" + Router__Messaging__ConsumerGroup: "vexlens" volumes: - *cert-volume ports: diff --git a/devops/compose/docker-compose.stella-services.yml b/devops/compose/docker-compose.stella-services.yml index bbcecc55b..05655c89c 100644 --- a/devops/compose/docker-compose.stella-services.yml +++ b/devops/compose/docker-compose.stella-services.yml @@ -300,6 +300,7 @@ services: STELLAOPS_SIGNALS_URL: "http://signals.stella-ops.local" STELLAOPS_ADVISORYAI_URL: "http://advisoryai.stella-ops.local" STELLAOPS_UNKNOWNS_URL: "http://unknowns.stella-ops.local" + STELLAOPS_BOOTSTRAP_KEY: "${AUTHORITY_BOOTSTRAP_APIKEY:-stellaops-dev-bootstrap-key}" Router__Enabled: "${PLATFORM_ROUTER_ENABLED:-true}" Router__Messaging__ConsumerGroup: "platform" volumes: @@ -768,6 +769,9 @@ services: ConnectionStrings__Redis: "cache.stella-ops.local:6379" Postgres__ConnectionString: "${STELLAOPS_POSTGRES_CONNECTION}" Postgres__SchemaName: "vexhub" + Authority__ResourceServer__Authority: "https://authority.stella-ops.local/" + Authority__ResourceServer__MetadataAddress: "https://authority.stella-ops.local/.well-known/openid-configuration" + Authority__ResourceServer__RequireHttpsMetadata: "false" Router__Enabled: "${VEXHUB_ROUTER_ENABLED:-true}" Router__Messaging__ConsumerGroup: "vexhub" volumes: @@ -795,6 +799,15 @@ services: <<: [*kestrel-cert, *router-microservice-defaults, *gc-light] ConnectionStrings__Default: "${STELLAOPS_POSTGRES_CONNECTION}" ConnectionStrings__Redis: "cache.stella-ops.local:6379" + Authority__ResourceServer__Authority: "https://authority.stella-ops.local/" + Authority__ResourceServer__MetadataAddress: "https://authority.stella-ops.local/.well-known/openid-configuration" + Authority__ResourceServer__RequireHttpsMetadata: "false" + Authority__ResourceServer__Audiences__0: "" + Authority__ResourceServer__BypassNetworks__0: "172.19.0.0/16" + Authority__ResourceServer__BypassNetworks__1: "127.0.0.1/32" + Authority__ResourceServer__BypassNetworks__2: "::1/128" + Authority__ResourceServer__BypassNetworks__3: "0.0.0.0/0" + Authority__ResourceServer__BypassNetworks__4: "::/0" Router__Enabled: "${VEXLENS_ROUTER_ENABLED:-true}" Router__Messaging__ConsumerGroup: "vexlens" volumes: diff --git a/devops/compose/postgres-init/14-platform-environment-settings.sql b/devops/compose/postgres-init/14-platform-environment-settings.sql index 3d7e1e30a..1f9267169 100644 --- a/devops/compose/postgres-init/14-platform-environment-settings.sql +++ b/devops/compose/postgres-init/14-platform-environment-settings.sql @@ -1,20 +1,13 @@ --- Platform environment_settings table for setup state and runtime config overrides. --- Used by SetupStateDetector to determine if setup wizard has been completed. +-- Platform environment_settings table for installation-scoped runtime config overrides. +-- Fresh compose databases should start without a SetupComplete marker so the +-- truthful bootstrap wizard can own first-run convergence. -- This is idempotent and safe to run on new compose databases. CREATE SCHEMA IF NOT EXISTS platform; CREATE TABLE IF NOT EXISTS platform.environment_settings ( - key VARCHAR(256) NOT NULL, - value TEXT NOT NULL, - tenant_id VARCHAR(128) NOT NULL DEFAULT '_system', - updated_by VARCHAR(256) NOT NULL DEFAULT 'system', - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (tenant_id, key) + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_by TEXT NOT NULL DEFAULT 'system' ); - --- Mark setup as complete for fresh installs (docker-compose local dev). --- The setup wizard can re-run and overwrite this if needed. -INSERT INTO platform.environment_settings (key, value, tenant_id, updated_by) -VALUES ('SetupComplete', 'true', '_system', 'postgres-init') -ON CONFLICT (tenant_id, key) DO NOTHING; diff --git a/docs/INSTALL_GUIDE.md b/docs/INSTALL_GUIDE.md index cabe498e2..7e36f9a88 100755 --- a/docs/INSTALL_GUIDE.md +++ b/docs/INSTALL_GUIDE.md @@ -161,6 +161,11 @@ Canonical policy for upgradeable on-prem installs: Notes: - Compose PostgreSQL bootstrap scripts in `devops/compose/postgres-init` run only on first database initialization. +- `devops/compose/postgres-init/14-platform-environment-settings.sql` now + leaves `platform.environment_settings` empty on fresh local databases so the + setup wizard owns first-run completion truth. Older local volumes with the + legacy `(tenant_id, key)` table shape are converged by Platform release + migration `064_EnvironmentSettingsInstallationScopeConvergence.sql`. - Startup-hosted migrations are currently wired only for selected modules; CLI coverage is also module-limited. - For the authoritative current-state module matrix, use `docs/db/MIGRATION_INVENTORY.md`. @@ -210,6 +215,10 @@ Verified current UI boundary on `2026-04-14`: `platform.setup_sessions` and owns only the five control-plane steps the running control plane can truthfully converge: PostgreSQL, Valkey, schema migrations, admin bootstrap, and crypto profile. +- The Admin step depends on Platform reaching Authority's internal bootstrap + endpoint with the shared bootstrap API key. In local compose, this is wired + by forwarding `AUTHORITY_BOOTSTRAP_APIKEY` into Platform as + `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. diff --git a/docs/implplan/SPRINT_20260413_004_Platform_ui_only_setup_bootstrap_closure.md b/docs/implplan/SPRINT_20260413_004_Platform_ui_only_setup_bootstrap_closure.md index 1555a04d9..ea4608b5c 100644 --- a/docs/implplan/SPRINT_20260413_004_Platform_ui_only_setup_bootstrap_closure.md +++ b/docs/implplan/SPRINT_20260413_004_Platform_ui_only_setup_bootstrap_closure.md @@ -114,6 +114,8 @@ Completion criteria: | 2026-04-14 | Rebuilt the Angular workspace after the secret-authority UI cutover and fixed downstream specs that still assumed the pre-cutover raw `CreateIntegrationRequest` wizard output. | Developer | | 2026-04-14 | Ran the live GitLab UI bootstrap proof with inline secret staging against the local stack after refreshing `secret/gitlab` in dev Vault. The resulting Playwright artifact `src/Web/StellaOps.Web/output/playwright/live-integrations-ui-bootstrap.json` recorded `16/16` healthy integrations, `16` successful test probes, and `0` failed integrations. | Developer | | 2026-04-14 | Closed the remaining web-suite caveat by synchronizing stale security/audit/settings/setup-wizard specs with the current shipped contracts and rerunning the deterministic web batches through the previously failing tail. Batch `27/33` passed with `79/79` tests, batch `28/33` passed with `65/65`, and batches `29-33/33` passed cleanly, leaving the default web batch lane green. | Developer | +| 2026-04-14 | Fixed the last local setup-finalize blocker by converging `platform.environment_settings` from the legacy tenant-scoped bootstrap shape to the installation-scoped schema expected by the truthful setup flow, updating the compose fallback, and adding regression coverage around the migration/runtime compatibility path. | Developer | +| 2026-04-14 | Re-ran the full setup wizard from scratch through `src/Web/StellaOps.Web/scripts/live-setup-wizard-full-bootstrap.mjs`. The refreshed artifact `src/Web/StellaOps.Web/output/playwright/live-setup-wizard-full-bootstrap.json` recorded `failedActionCount=0`, `runtimeIssueCount=0`, and final completion through `crypto-finalize-completed`, while `https://stella-ops.local/healthz` stayed `ready=true`. | Developer | ## Decisions & Risks - Decision: a truthful UI setup starts only after the control plane is already reachable in the browser. Docker/host/runtime bring-up remains a machine bootstrap concern, not a browser concern. @@ -121,6 +123,7 @@ Completion criteria: - Decision: secret material belongs in a secret authority, not in the integration catalog and not in frontend-only state. The UI must talk to a backend secret-staging contract that returns an authref binding. - Decision: the first shipped Secret Authority writer targets Vault KV v2 only. Other secrets-manager providers fail explicitly with `501 not_implemented` instead of pretending write support exists. - Decision: installation-scoped wizard progress is now persisted in `platform.setup_sessions`, and only non-sensitive draft values are stored there. +- Decision: `platform.environment_settings` is installation-scoped in both startup migrations and compose bootstrap fallbacks; local bootstrap must not preseed `SetupComplete` or carry tenant-scoped keys forward. - Decision: the live UI bootstrap artifact is considered green when the integration catalog converges to `16/16` healthy entries and the per-integration create/test/health checks succeed, even if background assistant/context requests are aborted during route transitions. - Risk: if the setup wizard continues to mix installation-scoped and tenant-scoped concerns, it will keep drifting into a misleading all-in-one setup surface that cannot be made truthful. - Risk: adding a secret staging API without strong audit and scope controls would weaken the platform security posture. diff --git a/docs/integrations/LOCAL_SERVICES.md b/docs/integrations/LOCAL_SERVICES.md index 44e722cf5..6fc391a05 100644 --- a/docs/integrations/LOCAL_SERVICES.md +++ b/docs/integrations/LOCAL_SERVICES.md @@ -105,6 +105,10 @@ node src/Web/StellaOps.Web/scripts/live-integrations-ui-bootstrap.mjs - The separate first-run setup wizard (`/setup-wizard/wizard`) now reaches the Platform setup API through the frontdoor and uses persisted, installation-scoped setup sessions for the five truthful control-plane steps. +- The wizard's Admin step uses Authority's internal bootstrap API, so the local + Platform container must receive the same bootstrap key via + `STELLAOPS_BOOTSTRAP_KEY` that Authority exposes through + `AUTHORITY_BOOTSTRAP_APIKEY`. Scripted convergence path: diff --git a/docs/modules/platform/platform-service.md b/docs/modules/platform/platform-service.md index b868badb9..b2fa20d03 100644 --- a/docs/modules/platform/platform-service.md +++ b/docs/modules/platform/platform-service.md @@ -263,6 +263,11 @@ reconfiguration checks. Current runtime behavior: - Authoritative wizard state is persisted in `platform.setup_sessions` via migration `063_PlatformSetupSessions.sql`. +- Installation-scoped environment settings and the `SetupComplete` marker now + converge through `platform.environment_settings` keyed only by `key`. + 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. diff --git a/docs/setup/setup-wizard-ux.md b/docs/setup/setup-wizard-ux.md index e359a6ce9..8b3be3310 100644 --- a/docs/setup/setup-wizard-ux.md +++ b/docs/setup/setup-wizard-ux.md @@ -28,9 +28,17 @@ design material later in this document. - The implemented UI bootstrap flow persists authoritative installation-scoped state in `platform.setup_sessions`. +- Fresh compose bootstrap no longer pre-seeds `SetupComplete=true`; a clean + local database now lands in the setup wizard until the control-plane steps + are actually finalized. Legacy local volumes are auto-converged by Platform + release migration `064_EnvironmentSettingsInstallationScopeConvergence.sql`. - The current live step inventory is limited to the five control-plane steps the running platform can truthfully validate and converge: `database`, `cache`, `migrations`, `admin`, and `crypto`. +- The `admin` step now seeds the same local standard-provider and superuser + 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. - `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/Platform/StellaOps.Platform.WebService/Program.cs b/src/Platform/StellaOps.Platform.WebService/Program.cs index e657ef133..42cb82ceb 100644 --- a/src/Platform/StellaOps.Platform.WebService/Program.cs +++ b/src/Platform/StellaOps.Platform.WebService/Program.cs @@ -189,14 +189,23 @@ 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; + builder.Services.AddHttpClient("AuthorityInternal", client => { var authorityUrl = builder.Configuration["STELLAOPS_AUTHORITY_URL"] ?? builder.Configuration["Authority:InternalUrl"] ?? "https://authority.stella-ops.local"; client.BaseAddress = new Uri(authorityUrl.TrimEnd('/') + "/"); - client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", - builder.Configuration["STELLAOPS_BOOTSTRAP_KEY"] ?? builder.Configuration["Authority:BootstrapKey"] ?? ""); + if (!string.IsNullOrWhiteSpace(authorityBootstrapKey)) + { + client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key", authorityBootstrapKey); + } client.Timeout = TimeSpan.FromSeconds(30); }); diff --git a/src/Platform/StellaOps.Platform.WebService/Services/PostgresEnvironmentSettingsStore.cs b/src/Platform/StellaOps.Platform.WebService/Services/PostgresEnvironmentSettingsStore.cs index c6d46321b..865cc556a 100644 --- a/src/Platform/StellaOps.Platform.WebService/Services/PostgresEnvironmentSettingsStore.cs +++ b/src/Platform/StellaOps.Platform.WebService/Services/PostgresEnvironmentSettingsStore.cs @@ -31,7 +31,8 @@ public sealed class PostgresEnvironmentSettingsStore : IEnvironmentSettingsStore private const string UpsertSql = """ INSERT INTO platform.environment_settings (key, value, updated_at, updated_by) VALUES ({0}, {1}, now(), {2}) - ON CONFLICT (key) DO UPDATE SET value = {1}, updated_at = now(), updated_by = {2} + ON CONFLICT ON CONSTRAINT environment_settings_pkey + DO UPDATE SET value = {1}, updated_at = now(), updated_by = {2} """; public PostgresEnvironmentSettingsStore( diff --git a/src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/064_EnvironmentSettingsInstallationScopeConvergence.sql b/src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/064_EnvironmentSettingsInstallationScopeConvergence.sql new file mode 100644 index 000000000..76d44087b --- /dev/null +++ b/src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/064_EnvironmentSettingsInstallationScopeConvergence.sql @@ -0,0 +1,72 @@ +-- Migration: 064_EnvironmentSettingsInstallationScopeConvergence +-- Purpose: Converge legacy platform.environment_settings bootstrap tables to the installation-scoped single-key contract. +-- Sprint: SPRINT_20260413_004_Platform_ui_only_setup_bootstrap_closure + +CREATE SCHEMA IF NOT EXISTS platform; + +CREATE TABLE IF NOT EXISTS platform.environment_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_by TEXT NOT NULL DEFAULT 'system' +); + +DO $$ +DECLARE + has_tenant_id BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'platform' + AND table_name = 'environment_settings' + AND column_name = 'tenant_id') + INTO has_tenant_id; + + IF has_tenant_id THEN + DROP TABLE IF EXISTS platform.environment_settings_installation_scope_tmp; + + CREATE TABLE platform.environment_settings_installation_scope_tmp ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_by TEXT NOT NULL DEFAULT 'system' + ); + + INSERT INTO platform.environment_settings_installation_scope_tmp (key, value, updated_at, updated_by) + SELECT legacy.key::text, + legacy.value, + COALESCE(legacy.updated_at, now()), + COALESCE(legacy.updated_by, 'system') + FROM ( + SELECT DISTINCT ON (key) + key, + value, + updated_at, + updated_by, + tenant_id + FROM platform.environment_settings + ORDER BY key, + updated_at DESC, + updated_by DESC, + tenant_id ASC, + value ASC + ) AS legacy + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, + updated_at = EXCLUDED.updated_at, + updated_by = EXCLUDED.updated_by; + + DROP TABLE platform.environment_settings; + ALTER TABLE platform.environment_settings_installation_scope_tmp + RENAME TO environment_settings; + ELSE + ALTER TABLE platform.environment_settings + ALTER COLUMN key TYPE TEXT, + ALTER COLUMN value TYPE TEXT, + ALTER COLUMN updated_by TYPE TEXT; + END IF; +END $$; + +COMMENT ON TABLE platform.environment_settings IS + 'Installation-scoped key-value store for platform environment settings and setup completion state.'; diff --git a/src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/065_EnvironmentSettingsPrimaryKeyNormalization.sql b/src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/065_EnvironmentSettingsPrimaryKeyNormalization.sql new file mode 100644 index 000000000..0e408430e --- /dev/null +++ b/src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/065_EnvironmentSettingsPrimaryKeyNormalization.sql @@ -0,0 +1,21 @@ +-- Migration: 065_EnvironmentSettingsPrimaryKeyNormalization +-- Purpose: Normalize the primary key constraint name after legacy environment_settings table convergence. +-- Sprint: SPRINT_20260413_004_Platform_ui_only_setup_bootstrap_closure + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint constraint_info + JOIN pg_class table_info + ON table_info.oid = constraint_info.conrelid + JOIN pg_namespace schema_info + ON schema_info.oid = table_info.relnamespace + WHERE schema_info.nspname = 'platform' + AND table_info.relname = 'environment_settings' + AND constraint_info.conname = 'environment_settings_installation_scope_tmp_pkey') + THEN + ALTER TABLE platform.environment_settings + RENAME CONSTRAINT environment_settings_installation_scope_tmp_pkey TO environment_settings_pkey; + END IF; +END $$; diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/EnvironmentSettingsLegacySchemaIntegrationTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/EnvironmentSettingsLegacySchemaIntegrationTests.cs new file mode 100644 index 000000000..d15abdce1 --- /dev/null +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/EnvironmentSettingsLegacySchemaIntegrationTests.cs @@ -0,0 +1,273 @@ +using Npgsql; +using StellaOps.Platform.WebService.Services; +using StellaOps.TestKit; +using StellaOps.TestKit.Fixtures; +using Xunit; + +namespace StellaOps.Platform.WebService.Tests; + +[Trait("Category", TestCategories.Integration)] +[Collection("Postgres")] +public sealed class EnvironmentSettingsLegacySchemaIntegrationTests +{ + private readonly PostgresFixture _fixture; + + public EnvironmentSettingsLegacySchemaIntegrationTests(PostgresFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task SetAsync_WithLegacyCompositePrimaryKey_UsesPrimaryKeyConstraintCompatibility() + { + await ResetLegacyEnvironmentSettingsSchemaAsync(); + await InsertLegacyRowAsync( + key: SetupStateDetector.SetupCompleteKey, + value: "false", + tenantId: "_system", + updatedBy: "postgres-init", + updatedAtUtc: DateTimeOffset.Parse("2026-04-14T08:55:02Z")); + + await using var dataSource = NpgsqlDataSource.Create(_fixture.ConnectionString); + var store = new PostgresEnvironmentSettingsStore(dataSource); + + await store.SetAsync( + SetupStateDetector.SetupCompleteKey, + "true", + "setup-wizard", + TestContext.Current.CancellationToken); + + await using var conn = new NpgsqlConnection(_fixture.ConnectionString); + await conn.OpenAsync(TestContext.Current.CancellationToken); + + await using var cmd = new NpgsqlCommand( + """ + SELECT key, value, tenant_id, updated_by + FROM platform.environment_settings + ORDER BY tenant_id, key + """, + conn); + + await using var reader = await cmd.ExecuteReaderAsync(TestContext.Current.CancellationToken); + Assert.True(await reader.ReadAsync(TestContext.Current.CancellationToken)); + Assert.Equal(SetupStateDetector.SetupCompleteKey, reader.GetString(0)); + Assert.Equal("true", reader.GetString(1)); + Assert.Equal("_system", reader.GetString(2)); + Assert.Equal("setup-wizard", reader.GetString(3)); + Assert.False(await reader.ReadAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Migration064_ConvergesLegacyEnvironmentSettingsTable_ToInstallationScopedShape() + { + await ResetLegacyEnvironmentSettingsSchemaAsync(); + await InsertLegacyRowAsync( + key: "ApiBaseUrls:Platform", + value: "https://tenant.stella-ops.local", + tenantId: "tenant-a", + updatedBy: "tenant-a", + updatedAtUtc: DateTimeOffset.Parse("2026-04-14T08:00:00Z")); + await InsertLegacyRowAsync( + key: "ApiBaseUrls:Platform", + value: "https://stella-ops.local", + tenantId: "_system", + updatedBy: "system", + updatedAtUtc: DateTimeOffset.Parse("2026-04-14T09:00:00Z")); + await InsertLegacyRowAsync( + key: SetupStateDetector.SetupCompleteKey, + value: "true", + tenantId: "_system", + updatedBy: "postgres-init", + updatedAtUtc: DateTimeOffset.Parse("2026-04-14T09:05:00Z")); + + await ExecuteMigrationAsync("044_PlatformEnvironmentSettings.sql"); + await ExecuteMigrationAsync("064_EnvironmentSettingsInstallationScopeConvergence.sql"); + await ExecuteMigrationAsync("065_EnvironmentSettingsPrimaryKeyNormalization.sql"); + + await using var conn = new NpgsqlConnection(_fixture.ConnectionString); + await conn.OpenAsync(TestContext.Current.CancellationToken); + + var hasTenantId = await ExecuteScalarAsync( + conn, + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'platform' + AND table_name = 'environment_settings' + AND column_name = 'tenant_id') + """); + Assert.False(hasTenantId); + + var primaryKeyColumns = new List(); + await using (var cmd = new NpgsqlCommand( + """ + SELECT tc.constraint_name, kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.table_schema = 'platform' + AND tc.table_name = 'environment_settings' + AND tc.constraint_type = 'PRIMARY KEY' + ORDER BY kcu.ordinal_position + """, + conn)) + await using (var reader = await cmd.ExecuteReaderAsync(TestContext.Current.CancellationToken)) + { + string? primaryKeyConstraintName = null; + while (await reader.ReadAsync(TestContext.Current.CancellationToken)) + { + primaryKeyConstraintName ??= reader.GetString(0); + primaryKeyColumns.Add(reader.GetString(1)); + } + + Assert.Equal("environment_settings_pkey", primaryKeyConstraintName); + } + + Assert.Equal(["key"], primaryKeyColumns); + + var baseUrl = await ExecuteScalarAsync( + conn, + """ + SELECT value + FROM platform.environment_settings + WHERE key = 'ApiBaseUrls:Platform' + """); + Assert.Equal("https://stella-ops.local", baseUrl); + + var rowCount = await ExecuteScalarAsync( + conn, + "SELECT COUNT(*) FROM platform.environment_settings"); + Assert.Equal(2L, rowCount); + } + + [Fact] + public async Task Migration065_RenamesTemporaryPrimaryKeyConstraint_OnAlreadyConvergedTable() + { + const string sql = """ + DROP SCHEMA IF EXISTS platform CASCADE; + CREATE SCHEMA platform; + + CREATE TABLE platform.environment_settings ( + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_by TEXT NOT NULL DEFAULT 'system', + CONSTRAINT environment_settings_installation_scope_tmp_pkey PRIMARY KEY (key) + ); + """; + + await using (var conn = new NpgsqlConnection(_fixture.ConnectionString)) + { + await conn.OpenAsync(TestContext.Current.CancellationToken); + await using var cmd = new NpgsqlCommand(sql, conn); + await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + await ExecuteMigrationAsync("065_EnvironmentSettingsPrimaryKeyNormalization.sql"); + + await using var verifyConn = new NpgsqlConnection(_fixture.ConnectionString); + await verifyConn.OpenAsync(TestContext.Current.CancellationToken); + + var constraintName = await ExecuteScalarAsync( + verifyConn, + """ + SELECT tc.constraint_name + FROM information_schema.table_constraints tc + WHERE tc.table_schema = 'platform' + AND tc.table_name = 'environment_settings' + AND tc.constraint_type = 'PRIMARY KEY' + """); + Assert.Equal("environment_settings_pkey", constraintName); + } + + private async Task ResetLegacyEnvironmentSettingsSchemaAsync() + { + const string sql = """ + DROP SCHEMA IF EXISTS platform CASCADE; + CREATE SCHEMA platform; + + CREATE TABLE platform.environment_settings ( + key VARCHAR(256) NOT NULL, + value TEXT NOT NULL, + tenant_id VARCHAR(128) NOT NULL DEFAULT '_system', + updated_by VARCHAR(256) NOT NULL DEFAULT 'system', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, key) + ); + """; + + await using var conn = new NpgsqlConnection(_fixture.ConnectionString); + await conn.OpenAsync(TestContext.Current.CancellationToken); + await using var cmd = new NpgsqlCommand(sql, conn); + await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private async Task InsertLegacyRowAsync( + string key, + string value, + string tenantId, + string updatedBy, + DateTimeOffset updatedAtUtc) + { + const string sql = """ + INSERT INTO platform.environment_settings (key, value, tenant_id, updated_by, updated_at) + VALUES (@key, @value, @tenantId, @updatedBy, @updatedAtUtc) + """; + + await using var conn = new NpgsqlConnection(_fixture.ConnectionString); + await conn.OpenAsync(TestContext.Current.CancellationToken); + await using var cmd = new NpgsqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@key", key); + cmd.Parameters.AddWithValue("@value", value); + cmd.Parameters.AddWithValue("@tenantId", tenantId); + cmd.Parameters.AddWithValue("@updatedBy", updatedBy); + cmd.Parameters.AddWithValue("@updatedAtUtc", updatedAtUtc.UtcDateTime); + await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private async Task ExecuteMigrationAsync(string fileName) + { + var sql = await File.ReadAllTextAsync(GetReleaseMigrationPath(fileName), TestContext.Current.CancellationToken); + + await using var conn = new NpgsqlConnection(_fixture.ConnectionString); + await conn.OpenAsync(TestContext.Current.CancellationToken); + await using var cmd = new NpgsqlCommand(sql, conn); + await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); + } + + private static async Task ExecuteScalarAsync(NpgsqlConnection conn, string sql) + { + await using var cmd = new NpgsqlCommand(sql, conn); + var result = await cmd.ExecuteScalarAsync(TestContext.Current.CancellationToken); + Assert.NotNull(result); + return (T)result!; + } + + private static string GetReleaseMigrationPath(string fileName) + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + var candidate = Path.Combine( + current.FullName, + "src", + "Platform", + "__Libraries", + "StellaOps.Platform.Database", + "Migrations", + "Release", + fileName); + + if (File.Exists(candidate)) + { + return candidate; + } + + current = current.Parent; + } + + throw new FileNotFoundException($"Could not locate Platform release migration {fileName}."); + } +} diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/EnvironmentSettingsMigrationScriptTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/EnvironmentSettingsMigrationScriptTests.cs new file mode 100644 index 000000000..717b56783 --- /dev/null +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/EnvironmentSettingsMigrationScriptTests.cs @@ -0,0 +1,110 @@ +using StellaOps.TestKit; + +namespace StellaOps.Platform.WebService.Tests; + +public sealed class EnvironmentSettingsMigrationScriptTests +{ + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Migration064_ConvergesLegacyTenantScopedEnvironmentSettingsTable() + { + var sql = File.ReadAllText(GetReleaseMigrationPath("064_EnvironmentSettingsInstallationScopeConvergence.sql")); + + Assert.Contains("column_name = 'tenant_id'", sql, StringComparison.Ordinal); + Assert.Contains("DISTINCT ON (key)", sql, StringComparison.Ordinal); + Assert.Contains("DROP TABLE platform.environment_settings;", sql, StringComparison.Ordinal); + Assert.Contains("RENAME TO environment_settings;", sql, StringComparison.Ordinal); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Migration065_NormalizesEnvironmentSettingsPrimaryKeyConstraintName() + { + var sql = File.ReadAllText(GetReleaseMigrationPath("065_EnvironmentSettingsPrimaryKeyNormalization.sql")); + + Assert.Contains("environment_settings_installation_scope_tmp_pkey", sql, StringComparison.Ordinal); + Assert.Contains("RENAME CONSTRAINT environment_settings_installation_scope_tmp_pkey TO environment_settings_pkey;", sql, StringComparison.Ordinal); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void Migration065_IsPresentInReleaseMigrationSequence() + { + var migrationNames = Directory.GetFiles(GetReleaseMigrationsDirectory(), "*.sql") + .Select(Path.GetFileName) + .Where(static name => name is not null) + .Select(static name => name!) + .OrderBy(static name => name, StringComparer.Ordinal) + .ToArray(); + + var index064 = Array.IndexOf(migrationNames, "064_EnvironmentSettingsInstallationScopeConvergence.sql"); + var index065 = Array.IndexOf(migrationNames, "065_EnvironmentSettingsPrimaryKeyNormalization.sql"); + + Assert.True(index064 >= 0, "Expected migration 064 to exist."); + Assert.True(index065 > index064, "Expected migration 065 to appear after migration 064."); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public void PostgresInitEnvironmentSettingsScript_MatchesInstallationScopedShape() + { + var sql = File.ReadAllText(GetComposeInitPath("14-platform-environment-settings.sql")); + + Assert.Contains("key TEXT PRIMARY KEY", sql, StringComparison.Ordinal); + Assert.DoesNotContain("tenant_id", sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("SetupComplete", sql, StringComparison.Ordinal); + } + + private static string GetReleaseMigrationPath(string fileName) + { + return Path.Combine(GetReleaseMigrationsDirectory(), fileName); + } + + private static string GetReleaseMigrationsDirectory() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + var candidate = Path.Combine( + current.FullName, + "src", + "Platform", + "__Libraries", + "StellaOps.Platform.Database", + "Migrations", + "Release"); + + if (Directory.Exists(candidate)) + { + return candidate; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not locate Platform release migrations directory."); + } + + private static string GetComposeInitPath(string fileName) + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + var candidate = Path.Combine( + current.FullName, + "devops", + "compose", + "postgres-init", + fileName); + + if (File.Exists(candidate)) + { + return candidate; + } + + current = current.Parent; + } + + throw new FileNotFoundException($"Could not locate compose postgres-init script {fileName}."); + } +} diff --git a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/SetupEndpointsTests.cs b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/SetupEndpointsTests.cs index 662e91855..d7e702fe7 100644 --- a/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/SetupEndpointsTests.cs +++ b/src/Platform/__Tests/StellaOps.Platform.WebService.Tests/SetupEndpointsTests.cs @@ -8,6 +8,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Http; @@ -253,6 +254,70 @@ public sealed class SetupEndpointsTests : IClassFixture + { + 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" }); + }), + new Dictionary + { + ["STELLAOPS_AUTHORITY_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 SkipRequiredStep_ReturnsProblemDetails() @@ -288,10 +353,19 @@ public sealed class SetupEndpointsTests : IClassFixture CreateSetupFactory( IPlatformSetupStore? setupStore = null, - HttpMessageHandler? authorityHandler = null) + HttpMessageHandler? authorityHandler = null, + IDictionary? configuration = null) { return _factory.WithWebHostBuilder(builder => { + if (configuration is not null) + { + builder.ConfigureAppConfiguration((_, configBuilder) => + { + configBuilder.AddInMemoryCollection(configuration); + }); + } + builder.ConfigureTestServices(services => { if (setupStore is not null) diff --git a/src/Router/StellaOps.Gateway.WebService/Middleware/RouteDispatchMiddleware.cs b/src/Router/StellaOps.Gateway.WebService/Middleware/RouteDispatchMiddleware.cs index 7d6dbe14d..c7e880247 100644 --- a/src/Router/StellaOps.Gateway.WebService/Middleware/RouteDispatchMiddleware.cs +++ b/src/Router/StellaOps.Gateway.WebService/Middleware/RouteDispatchMiddleware.cs @@ -23,6 +23,21 @@ public sealed class RouteDispatchMiddleware "TE", "Trailers", "Transfer-Encoding", "Upgrade" }; + private static readonly HashSet ContentHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "Allow", + "Content-Disposition", + "Content-Encoding", + "Content-Language", + "Content-Length", + "Content-Location", + "Content-MD5", + "Content-Range", + "Content-Type", + "Expires", + "Last-Modified" + }; + // ReverseProxy paths that are legitimate browser navigation targets (e.g. OIDC flows) // and must NOT be redirected to the SPA fallback. private static readonly string[] BrowserProxyPaths = ["/connect", "/.well-known"]; @@ -203,19 +218,10 @@ public sealed class RouteDispatchMiddleware var client = _httpClientFactory.CreateClient("RouteDispatch"); client.Timeout = TimeSpan.FromSeconds(30); - var upstreamRequest = new HttpRequestMessage(new HttpMethod(context.Request.Method), upstreamUri); + using var upstreamRequest = new HttpRequestMessage(new HttpMethod(context.Request.Method), upstreamUri); + var hasRequestContent = await AttachRequestContentAsync(context, upstreamRequest); - // Copy request headers (excluding hop-by-hop) - foreach (var header in context.Request.Headers) - { - if (HopByHopHeaders.Contains(header.Key) || - header.Key.Equals("Host", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - upstreamRequest.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); - } + CopyRequestHeaders(context.Request.Headers, upstreamRequest, hasRequestContent); // Inject configured headers foreach (var (key, value) in route.Headers) @@ -223,16 +229,6 @@ public sealed class RouteDispatchMiddleware upstreamRequest.Headers.TryAddWithoutValidation(key, value); } - // Copy request body for methods that support it - if (context.Request.ContentLength > 0 || context.Request.ContentType is not null) - { - upstreamRequest.Content = new StreamContent(context.Request.Body); - if (context.Request.ContentType is not null) - { - upstreamRequest.Content.Headers.TryAddWithoutValidation("Content-Type", context.Request.ContentType); - } - } - HttpResponseMessage upstreamResponse; try { @@ -648,6 +644,63 @@ public sealed class RouteDispatchMiddleware return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase); } + private static async Task AttachRequestContentAsync(HttpContext context, HttpRequestMessage upstreamRequest) + { + if (!HasIncomingRequestContent(context.Request)) + { + return false; + } + + if (context.Request.Body.CanSeek && context.Request.Body.Position != 0) + { + context.Request.Body.Position = 0; + } + + await using var bodyBuffer = new MemoryStream(); + await context.Request.Body.CopyToAsync(bodyBuffer, context.RequestAborted); + + if (context.Request.Body.CanSeek) + { + context.Request.Body.Position = 0; + } + + upstreamRequest.Content = new ByteArrayContent(bodyBuffer.ToArray()); + return true; + } + + private static void CopyRequestHeaders( + IHeaderDictionary sourceHeaders, + HttpRequestMessage upstreamRequest, + bool hasRequestContent) + { + foreach (var header in sourceHeaders) + { + if (HopByHopHeaders.Contains(header.Key) || + header.Key.Equals("Host", StringComparison.OrdinalIgnoreCase) || + header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (hasRequestContent && + upstreamRequest.Content is not null && + IsContentHeader(header.Key)) + { + upstreamRequest.Content.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); + continue; + } + + upstreamRequest.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); + } + } + + private static bool HasIncomingRequestContent(HttpRequest request) => + request.ContentLength.GetValueOrDefault() > 0 || + !string.IsNullOrWhiteSpace(request.ContentType); + + private static bool IsContentHeader(string headerName) + => ContentHeaders.Contains(headerName); + private static bool ShouldServeSpaFallback(string relativePath) { if (!System.IO.Path.HasExtension(relativePath)) diff --git a/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareReverseProxyTests.cs b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareReverseProxyTests.cs new file mode 100644 index 000000000..23c24dc25 --- /dev/null +++ b/src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Middleware/RouteDispatchMiddlewareReverseProxyTests.cs @@ -0,0 +1,110 @@ +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using StellaOps.Gateway.WebService.Middleware; +using StellaOps.Gateway.WebService.Routing; +using StellaOps.Router.Gateway.Configuration; + +namespace StellaOps.Gateway.WebService.Tests.Middleware; + +[Trait("Category", "Unit")] +public sealed class RouteDispatchMiddlewareReverseProxyTests +{ + [Fact] + public async Task InvokeAsync_ReverseProxyRoute_RewindsAndForwardsJsonBody() + { + var resolver = new StellaOpsRouteResolver( + [ + new StellaOpsRoute + { + Type = StellaOpsRouteType.ReverseProxy, + Path = "/api/v1/setup", + TranslatesTo = "http://platform.stella-ops.local/api/v1/setup" + } + ]); + + var handler = new RecordingHttpMessageHandler(); + var httpClientFactory = new Mock(); + httpClientFactory + .Setup(factory => factory.CreateClient(It.IsAny())) + .Returns(new HttpClient(handler)); + + var nextCalled = false; + var middleware = new RouteDispatchMiddleware( + _ => + { + nextCalled = true; + return Task.CompletedTask; + }, + resolver, + httpClientFactory.Object, + NullLogger.Instance); + + const string payload = """{"provider":"standard","email":"admin@stella-ops.local"}"""; + var requestBody = new MemoryStream(Encoding.UTF8.GetBytes(payload)); + requestBody.Position = requestBody.Length; + + var context = new DefaultHttpContext(); + context.Request.Method = HttpMethods.Put; + context.Request.Path = "/api/v1/setup/sessions/session-123/config"; + context.Request.QueryString = new QueryString("?draft=true"); + context.Request.Body = requestBody; + context.Request.ContentLength = requestBody.Length; + context.Request.ContentType = "application/json; charset=utf-8"; + context.Request.Headers["X-Correlation-Id"] = "corr-123"; + context.Response.Body = new MemoryStream(); + + await middleware.InvokeAsync(context); + + nextCalled.Should().BeFalse(); + handler.RequestUri.Should().Be("http://platform.stella-ops.local/api/v1/setup/sessions/session-123/config?draft=true"); + handler.Method.Should().Be(HttpMethod.Put); + handler.ContentType.Should().Be("application/json; charset=utf-8"); + handler.Body.Should().Be(payload); + handler.Headers.Should().ContainKey("X-Correlation-Id"); + handler.Headers["X-Correlation-Id"].Should().ContainSingle().Which.Should().Be("corr-123"); + + context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + context.Response.Body.Position = 0; + var responseBody = await new StreamReader(context.Response.Body).ReadToEndAsync(); + responseBody.Should().Contain("\"saved\":true"); + } + + private sealed class RecordingHttpMessageHandler : HttpMessageHandler + { + public string? RequestUri { get; private set; } + public HttpMethod? Method { get; private set; } + public string? Body { get; private set; } + public string? ContentType { get; private set; } + public Dictionary Headers { get; } = new(StringComparer.OrdinalIgnoreCase); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestUri = request.RequestUri?.ToString(); + Method = request.Method; + + foreach (var header in request.Headers) + { + Headers[header.Key] = header.Value.ToArray(); + } + + if (request.Content is not null) + { + foreach (var header in request.Content.Headers) + { + Headers[header.Key] = header.Value.ToArray(); + } + + ContentType = request.Content.Headers.ContentType?.ToString(); + Body = await request.Content.ReadAsStringAsync(cancellationToken); + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"saved":true}""", Encoding.UTF8, "application/json") + }; + } + } +} diff --git a/src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs b/src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs index 1439a23e8..73342df2c 100644 --- a/src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs +++ b/src/Web/StellaOps.Web/scripts/live-frontdoor-auth.mjs @@ -13,7 +13,9 @@ const outputDirectory = path.join(webRoot, 'output', 'playwright'); const DEFAULT_BASE_URL = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local'; const DEFAULT_USERNAME = process.env.STELLAOPS_FRONTDOOR_USERNAME?.trim() || 'admin'; -const DEFAULT_PASSWORD = process.env.STELLAOPS_FRONTDOOR_PASSWORD?.trim() || 'Admin@Stella2026!'; +const DEFAULT_PASSWORD_CANDIDATES = process.env.STELLAOPS_FRONTDOOR_PASSWORD?.trim() + ? [process.env.STELLAOPS_FRONTDOOR_PASSWORD.trim()] + : ['Admin@Stella2026!', 'Admin@Stella1']; const DEFAULT_STATE_PATH = path.join(outputDirectory, 'live-frontdoor-auth-state.json'); const DEFAULT_REPORT_PATH = path.join(outputDirectory, 'live-frontdoor-auth-report.json'); @@ -66,17 +68,60 @@ async function waitForAuthTransition(page, usernameField, passwordField, timeout }).catch(() => {}), usernameField.waitFor({ state: 'visible', timeout: timeoutMs }).catch(() => {}), passwordField.waitFor({ state: 'visible', timeout: timeoutMs }).catch(() => {}), - page.waitForFunction(() => Boolean(sessionStorage.getItem('stellaops.auth.session.full')), null, { + page.waitForFunction( + () => + Boolean(sessionStorage.getItem('stellaops.auth.session.full')) + || Boolean(localStorage.getItem('stellaops.auth.session.full')), + null, + { timeout: timeoutMs, - }).catch(() => {}), + }, + ).catch(() => {}), page.waitForTimeout(timeoutMs), ]); } +async function hasBrowserSession(page) { + return page.evaluate( + () => + Boolean(sessionStorage.getItem('stellaops.auth.session.full')) + || Boolean(localStorage.getItem('stellaops.auth.session.full')), + ).catch(() => false); +} + +async function ensureAuthorityLoginReachable(page, baseUrl, signInTrigger, usernameField, passwordField) { + const hasLoginForm = async () => + (await usernameField.count()) > 0 + && (await passwordField.count()) > 0; + + if (page.url().includes('/connect/authorize') || await hasLoginForm()) { + return; + } + + const clicked = await clickIfVisible(signInTrigger, 10_000); + if (clicked) { + await waitForAuthTransition(page, usernameField, passwordField, 10_000); + } + + if (page.url().includes('/connect/authorize') || await hasLoginForm()) { + return; + } + + await page.goto(`${baseUrl}/connect/authorize`, { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }).catch(() => {}); + await waitForAuthTransition(page, usernameField, passwordField, 10_000); +} + export async function authenticateFrontdoor(options = {}) { const baseUrl = options.baseUrl?.trim() || DEFAULT_BASE_URL; const username = options.username?.trim() || DEFAULT_USERNAME; - const password = options.password?.trim() || DEFAULT_PASSWORD; + const passwordCandidates = Array.isArray(options.passwordCandidates) && options.passwordCandidates.length > 0 + ? options.passwordCandidates.map((value) => String(value ?? '').trim()).filter(Boolean) + : options.password?.trim() + ? [options.password.trim()] + : DEFAULT_PASSWORD_CANDIDATES; const statePath = options.statePath || DEFAULT_STATE_PATH; const reportPath = options.reportPath || DEFAULT_REPORT_PATH; const headless = options.headless ?? true; @@ -160,19 +205,17 @@ export async function authenticateFrontdoor(options = {}) { 'input[type="password"]', ]); - const signInClicked = await clickIfVisible(signInTrigger); - if (signInClicked) { - await waitForAuthTransition(page, usernameField, passwordField); - } else { - await page.waitForTimeout(1_500); - } + await ensureAuthorityLoginReachable(page, baseUrl, signInTrigger, usernameField, passwordField); const hasLoginForm = (await usernameField.count()) > 0 && (await passwordField.count()) > 0; if (page.url().includes('/connect/authorize') || hasLoginForm) { - const filledUser = await fillIfVisible(usernameField, username); - const filledPassword = await fillIfVisible(passwordField, password); + await Promise.all([ + usernameField.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {}), + passwordField.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {}), + ]); - if (!filledUser || !filledPassword) { + const filledUser = await fillIfVisible(usernameField, username); + if (!filledUser) { throw new Error(`Authority login form was reached at ${page.url()} but the credentials fields were not interactable.`); } @@ -184,25 +227,52 @@ export async function authenticateFrontdoor(options = {}) { 'button:has-text("Login")', ]); - await submitButton.click({ timeout: 10_000 }); + let authenticated = false; + for (const candidate of passwordCandidates) { + const filledPassword = await fillIfVisible(passwordField, candidate); + if (!filledPassword) { + throw new Error(`Authority login form was reached at ${page.url()} but the password field was not interactable.`); + } - await Promise.race([ - page.waitForURL( - (url) => !url.toString().includes('/connect/authorize') && !url.toString().includes('/auth/callback'), - { timeout: 30_000 }, - ).catch(() => {}), - page.waitForFunction(() => Boolean(sessionStorage.getItem('stellaops.auth.session.full')), null, { - timeout: 30_000, - }).catch(() => {}), - ]); + await submitButton.click({ timeout: 10_000 }); + + await Promise.race([ + page.waitForURL( + (url) => !url.toString().includes('/connect/authorize') && !url.toString().includes('/auth/callback'), + { timeout: 30_000 }, + ).catch(() => {}), + page.waitForFunction( + () => + Boolean(sessionStorage.getItem('stellaops.auth.session.full')) + || Boolean(localStorage.getItem('stellaops.auth.session.full')), + null, + { + timeout: 30_000, + }, + ).catch(() => {}), + ]); + + authenticated = await hasBrowserSession(page); + if (authenticated) { + break; + } + + if (!page.url().includes('/connect/authorize')) { + break; + } + } } await waitForShell(page); await page.waitForTimeout(2_500); const sessionStatus = await page.evaluate(() => ({ - hasFullSession: Boolean(sessionStorage.getItem('stellaops.auth.session.full')), - hasSessionInfo: Boolean(sessionStorage.getItem('stellaops.auth.session.info')), + hasFullSession: + Boolean(sessionStorage.getItem('stellaops.auth.session.full')) + || Boolean(localStorage.getItem('stellaops.auth.session.full')), + hasSessionInfo: + Boolean(sessionStorage.getItem('stellaops.auth.session.info')) + || Boolean(localStorage.getItem('stellaops.auth.session.info')), })); const signInStillVisible = await signInTrigger.isVisible().catch(() => false); if (!sessionStatus.hasFullSession || (!page.url().includes('/connect/authorize') && signInStillVisible)) { diff --git a/src/Web/StellaOps.Web/scripts/live-setup-wizard-full-bootstrap.mjs b/src/Web/StellaOps.Web/scripts/live-setup-wizard-full-bootstrap.mjs index edf2521ea..d207e06b0 100644 --- a/src/Web/StellaOps.Web/scripts/live-setup-wizard-full-bootstrap.mjs +++ b/src/Web/StellaOps.Web/scripts/live-setup-wizard-full-bootstrap.mjs @@ -347,7 +347,7 @@ async function applyStep(page, currentStepId, nextStepId) { && response.url().includes(`/steps/${currentStepId}/apply`), { timeout: 30_000 }, ), - clickPrimaryAction(page, /^Apply and Continue$/), + clickPrimaryAction(page, /Apply and Continue/i), ]); if (nextStepId) { @@ -436,11 +436,6 @@ async function main() { ); await settle(page, 1000); - await ensureFieldValue(page, '#db-host', 'db.stella-ops.local'); - await ensureFieldValue(page, '#db-port', '5432'); - await ensureFieldValue(page, '#db-database', 'stellaops_platform'); - await ensureFieldValue(page, '#db-user', 'stellaops'); - await ensureFieldValue(page, '#db-password', 'stellaops'); const databaseValidated = await validateDatabase(page); await applyStep(page, 'database', 'cache'); results.push({ @@ -456,10 +451,6 @@ async function main() { { timeout: 30_000 }, ); await settle(page, 750); - await ensureFieldValue(page, '#cache-host', 'cache.stella-ops.local'); - await ensureFieldValue(page, '#cache-port', '6379'); - await fillIfVisible(page, '#cache-password', ''); - await ensureFieldValue(page, '#cache-database', '0'); await applyStep(page, 'cache', 'migrations'); results.push({ action: 'cache-step-completed', diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.spec.ts index b058810d1..eb1f160d9 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter, Router } from '@angular/router'; import { of, throwError } from 'rxjs'; @@ -6,12 +6,14 @@ import { SetupSession } from '../models/setup-wizard.models'; import { SetupWizardApiService } from '../services/setup-wizard-api.service'; import { SetupWizardStateService } from '../services/setup-wizard-state.service'; import { SetupWizardComponent } from './setup-wizard.component'; +import { DoctorRecheckService } from '../../doctor/services/doctor-recheck.service'; describe('SetupWizardComponent', () => { let component: SetupWizardComponent; let fixture: ComponentFixture; let stateService: SetupWizardStateService; let apiService: jasmine.SpyObj; + let doctorRecheckService: jasmine.SpyObj; let router: Router; const freshSession: SetupSession = { @@ -66,6 +68,19 @@ describe('SetupWizardComponent', () => { ], }; + const cryptoPendingSession: SetupSession = { + ...freshSession, + completedSteps: ['database', 'cache', 'migrations', 'admin'], + currentStep: 'crypto', + steps: [ + { stepId: 'database', status: 'completed' }, + { stepId: 'cache', status: 'completed' }, + { stepId: 'migrations', status: 'completed' }, + { stepId: 'admin', status: 'completed' }, + { stepId: 'crypto', status: 'in_progress' }, + ], + }; + beforeEach(async () => { const apiSpy = jasmine.createSpyObj('SetupWizardApiService', [ 'createSession', @@ -77,6 +92,7 @@ describe('SetupWizardComponent', () => { 'runValidationChecks', 'finalizeSetup', ]); + const doctorRecheckSpy = jasmine.createSpyObj('DoctorRecheckService', ['offerRecheck']); apiSpy.createSession.and.returnValue(of(freshSession)); apiSpy.getCurrentSession.and.returnValue(of(cacheSession)); @@ -111,6 +127,7 @@ describe('SetupWizardComponent', () => { providers: [ SetupWizardStateService, { provide: SetupWizardApiService, useValue: apiSpy }, + { provide: DoctorRecheckService, useValue: doctorRecheckSpy }, provideRouter([]), ], }).compileComponents(); @@ -119,97 +136,110 @@ describe('SetupWizardComponent', () => { component = fixture.componentInstance; stateService = TestBed.inject(SetupWizardStateService); apiService = TestBed.inject(SetupWizardApiService) as jasmine.SpyObj; + doctorRecheckService = TestBed.inject(DoctorRecheckService) as jasmine.SpyObj; router = TestBed.inject(Router); }); - it('initializes fresh sessions on the welcome step', fakeAsync(() => { + it('initializes fresh sessions on the welcome step', () => { fixture.detectChanges(); - tick(); expect(apiService.createSession).toHaveBeenCalled(); expect(stateService.session()).toEqual(freshSession); expect(stateService.currentStepId()).toBe('welcome'); expect(apiService.runValidationChecks).not.toHaveBeenCalled(); - })); + }); - it('starts the truthful wizard on database after the welcome step', fakeAsync(() => { + it('starts the truthful wizard on database after the welcome step', () => { fixture.detectChanges(); - tick(); component.onWelcomeStart(); expect(stateService.currentStepId()).toBe('database'); expect(apiService.runValidationChecks).toHaveBeenCalledWith('test-session-123', 'database'); - })); + }); - it('moves to cache when the current database step is already completed', fakeAsync(() => { + it('moves to cache when the current database step is already completed', () => { apiService.createSession.and.returnValue(of(databaseSession)); fixture.detectChanges(); - tick(); stateService.updateStepStatus('database', 'completed'); component.onNext(); expect(stateService.currentStepId()).toBe('cache'); expect(apiService.runValidationChecks).toHaveBeenCalledWith('test-session-123', 'cache'); - })); + }); - it('applies the current step through save -> apply -> refresh', fakeAsync(() => { + it('applies the current step through save -> apply -> refresh', () => { apiService.createSession.and.returnValue(of(databaseSession)); fixture.detectChanges(); - tick(); component.onExecuteStep(); - tick(); expect(apiService.saveDraftConfig).toHaveBeenCalledWith('test-session-123', {}); expect(apiService.applyStep).toHaveBeenCalledWith('test-session-123', 'database', {}); expect(apiService.getCurrentSession).toHaveBeenCalled(); expect(stateService.currentStepId()).toBe('cache'); - })); + expect(doctorRecheckService.offerRecheck).not.toHaveBeenCalled(); + }); - it('probes the current step through save -> probe -> refresh', fakeAsync(() => { + it('probes the current step through save -> probe -> refresh', () => { apiService.createSession.and.returnValue(of(databaseSession)); fixture.detectChanges(); - tick(); component.onTestConnection(); - tick(); expect(apiService.saveDraftConfig).toHaveBeenCalledWith('test-session-123', {}); expect(apiService.probeStep).toHaveBeenCalledWith('test-session-123', 'database', {}); expect(stateService.currentStepId()).toBe('database'); - })); + }); - it('does not call skip when the current step is not skippable', fakeAsync(() => { + it('does not call skip when the current step is not skippable', () => { apiService.createSession.and.returnValue(of(databaseSession)); fixture.detectChanges(); - tick(); component.onSkipStep(); expect(apiService.skipStep).not.toHaveBeenCalled(); - })); + }); - it('finalizes the installation when all required steps are complete', fakeAsync(() => { + it('finalizes the installation when all required steps are complete', () => { spyOn(router, 'navigate'); apiService.createSession.and.returnValue(of(completedSession)); fixture.detectChanges(); - tick(); component.onComplete(); - tick(); expect(apiService.finalizeSetup).toHaveBeenCalledWith('test-session-123'); expect(router.navigate).toHaveBeenCalledWith(['/']); - })); + }); - it('surfaces finalize errors without navigating away', fakeAsync(() => { + it('applies the last pending required step before finalizing the installation', () => { + spyOn(router, 'navigate'); + apiService.createSession.and.returnValue(of(cryptoPendingSession)); + apiService.saveDraftConfig.and.returnValue(of(cryptoPendingSession)); + apiService.applyStep.and.returnValue(of({ + stepId: 'crypto', + status: 'completed', + message: 'Crypto step applied', + canRetry: true, + })); + + fixture.detectChanges(); + + component.onComplete(); + + expect(apiService.saveDraftConfig).toHaveBeenCalledWith('test-session-123', {}); + expect(apiService.applyStep).toHaveBeenCalledWith('test-session-123', 'crypto', {}); + expect(apiService.finalizeSetup).toHaveBeenCalledWith('test-session-123'); + expect(router.navigate).toHaveBeenCalledWith(['/']); + }); + + it('surfaces finalize errors without navigating away', () => { spyOn(router, 'navigate'); apiService.createSession.and.returnValue(of(completedSession)); apiService.finalizeSetup.and.returnValue( @@ -217,12 +247,10 @@ describe('SetupWizardComponent', () => { ); fixture.detectChanges(); - tick(); component.onComplete(); - tick(); expect(stateService.error()).toBe('Finalize backend unavailable'); expect(router.navigate).not.toHaveBeenCalled(); - })); + }); }); 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 122892227..9313ac2c6 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 @@ -27,7 +27,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { SetupWizardStateService } from '../services/setup-wizard-state.service'; import { SetupWizardApiService } from '../services/setup-wizard-api.service'; -import { StepContentComponent } from './step-content.component'; +import { mergeSetupStepLocalDefaults, StepContentComponent } from './step-content.component'; import { SetupStep, SetupStepId, @@ -1107,24 +1107,15 @@ export class SetupWizardComponent implements OnInit, OnDestroy { onComplete(): void { const session = this.state.session(); + const step = this.state.currentStep(); if (!session) return; - this.state.executing.set(true); + if (step && step.id !== 'welcome' && step.status !== 'completed' && step.status !== 'skipped') { + this.applyCurrentStep(step, () => this.finalizeSession(session.sessionId, step.id)); + return; + } - this.api.finalizeSetup(session.sessionId).subscribe({ - next: (result) => { - this.state.executing.set(false); - if (result.success) { - this.router.navigate(['/']); - } else { - this.state.error.set(result.message); - } - }, - error: (err) => { - this.state.executing.set(false); - this.state.error.set(err?.message ?? 'Failed to finalize setup'); - }, - }); + this.finalizeSession(session.sessionId); } onCancel(): void { @@ -1170,6 +1161,8 @@ export class SetupWizardComponent implements OnInit, OnDestroy { return; } + this.ensureCurrentStepDefaults(); + if (!currentStepId || currentStepId === 'welcome') { onSaved?.(session); return; @@ -1190,6 +1183,16 @@ export class SetupWizardComponent implements OnInit, OnDestroy { }); } + private ensureCurrentStepDefaults(): void { + const step = this.state.currentStep(); + if (!step || step.id === 'welcome') { + return; + } + + const merged = mergeSetupStepLocalDefaults(step.id, this.state.configValues()); + this.state.setConfigValues(merged); + } + private syncSessionFromBackend(preferredStepId: SetupStepId | null = null): void { this.api.getCurrentSession().subscribe({ next: (session) => { @@ -1237,7 +1240,7 @@ export class SetupWizardComponent implements OnInit, OnDestroy { }); } - private applyCurrentStep(step: SetupStep): void { + private applyCurrentStep(step: SetupStep, onSuccess?: () => void): void { const session = this.state.session(); if (!session) { return; @@ -1265,6 +1268,11 @@ export class SetupWizardComponent implements OnInit, OnDestroy { this.doctorRecheck.offerRecheck(step.id, step.name); } + if (onSuccess) { + onSuccess(); + return; + } + this.syncSessionFromBackend(); }, error: (err) => { @@ -1276,5 +1284,31 @@ export class SetupWizardComponent implements OnInit, OnDestroy { }); }); } + + private finalizeSession(sessionId: string, preferredStepId: SetupStepId | null = null): void { + this.state.executing.set(true); + + this.api.finalizeSetup(sessionId).subscribe({ + next: (result) => { + this.state.executing.set(false); + if (result.success) { + this.router.navigate(['/']); + return; + } + + this.state.error.set(result.message); + if (preferredStepId) { + this.syncSessionFromBackend(preferredStepId); + } + }, + error: (err) => { + this.state.executing.set(false); + this.state.error.set(err?.message ?? 'Failed to finalize setup'); + if (preferredStepId) { + this.syncSessionFromBackend(preferredStepId); + } + }, + }); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.component.spec.ts index b64ce2075..05bc2ef48 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.component.spec.ts @@ -38,6 +38,7 @@ describe('StepContentComponent', () => { status: 'pending', }; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [StepContentComponent], 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 304edb31c..0a7d3adf4 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 @@ -38,6 +38,74 @@ import { createIntegrationInstance, } from '../models/setup-wizard.models'; +const LOCAL_AUTHORITY_DEFAULTS: Record = { + 'authority.provider': 'standard', + 'authority.standard.minLength': '12', + 'authority.standard.requireUppercase': 'true', + 'authority.standard.requireLowercase': 'true', + 'authority.standard.requireDigit': 'true', + 'authority.standard.requireSpecialChar': 'true', +}; + +const LOCAL_ADMIN_DEFAULTS: Record = { + ...LOCAL_AUTHORITY_DEFAULTS, + 'users.superuser.username': 'admin', + 'users.superuser.email': 'admin@stella-ops.local', + 'users.superuser.password': 'Admin@Stella1', +}; + +export const SETUP_STEP_LOCAL_DEFAULTS: Record> = { + database: { + 'database.host': 'db.stella-ops.local', + 'database.port': '5432', + 'database.database': 'stellaops_platform', + 'database.user': 'stellaops', + 'database.password': 'stellaops', + }, + cache: { + 'cache.host': 'cache.stella-ops.local', + 'cache.port': '6379', + 'cache.database': '0', + }, + admin: LOCAL_ADMIN_DEFAULTS, + authority: LOCAL_AUTHORITY_DEFAULTS, + users: { + 'users.superuser.username': 'admin', + 'users.superuser.email': 'admin@stella-ops.local', + 'users.superuser.password': 'Admin@Stella1', + }, + crypto: { + 'crypto.provider': 'default', + }, + sources: { + 'sources.mode': 'custom', + }, + telemetry: { + 'telemetry.otlpEndpoint': 'http://localhost:4317', + 'telemetry.serviceName': 'stellaops', + }, +}; + +export function mergeSetupStepLocalDefaults( + stepId: SetupStepId, + configValues: Record, +): Record { + const defaults = SETUP_STEP_LOCAL_DEFAULTS[stepId]; + if (!defaults) { + return { ...configValues }; + } + + const merged = { ...configValues }; + for (const [key, value] of Object.entries(defaults)) { + const current = merged[key]; + if (typeof current !== 'string' || current.trim().length === 0) { + merged[key] = value; + } + } + + return merged; +} + /** * Step content component. * Dynamically renders configuration forms based on step type. @@ -1813,50 +1881,15 @@ export class StepContentComponent { 'https://mirrors.stella-ops.org/feeds/', ]); - /** Sensible defaults for local/development setup. */ - private static readonly LOCAL_DEFAULTS: Record> = { - database: { - 'database.host': 'db.stella-ops.local', - 'database.port': '5432', - 'database.database': 'stellaops_platform', - 'database.user': 'stellaops', - 'database.password': 'stellaops', - }, - cache: { - 'cache.host': 'cache.stella-ops.local', - 'cache.port': '6379', - 'cache.database': '0', - }, - authority: { - 'authority.provider': 'standard', - 'authority.standard.minLength': '12', - 'authority.standard.requireUppercase': 'true', - 'authority.standard.requireLowercase': 'true', - 'authority.standard.requireDigit': 'true', - 'authority.standard.requireSpecialChar': 'true', - }, - users: { - 'users.superuser.username': 'admin', - 'users.superuser.email': 'admin@stella-ops.local', - 'users.superuser.password': 'Admin@Stella1', - }, - crypto: { - 'crypto.provider': 'default', - }, - sources: { - 'sources.mode': 'custom', - }, - telemetry: { - 'telemetry.otlpEndpoint': 'http://localhost:4317', - 'telemetry.serviceName': 'stellaops', - }, - }; - /** Emit defaults for the current step if no values are set yet. */ private readonly defaultsEffect = effect(() => { - const step = this.step(); + const step = this.tryGetStep(); + if (!step) { + return; + } + const config = this.configValues(); - const defaults = StepContentComponent.LOCAL_DEFAULTS[step.id]; + const defaults = SETUP_STEP_LOCAL_DEFAULTS[step.id]; if (defaults) { for (const [key, value] of Object.entries(defaults)) { if (!config[key]) { @@ -1894,6 +1927,14 @@ export class StepContentComponent { } }); + private tryGetStep(): SetupStep | null { + try { + return this.step(); + } catch { + return null; + } + } + // Source feed mode: 'mirror' (Stella Ops pre-aggregated) or 'custom' (individual feeds) readonly sourceFeedMode = signal<'mirror' | 'custom' | null>(null); 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 new file mode 100644 index 000000000..b354efe6e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/step-content.defaults.spec.ts @@ -0,0 +1,36 @@ +import { + SETUP_STEP_LOCAL_DEFAULTS, + mergeSetupStepLocalDefaults, +} from './step-content.component'; + +describe('StepContentComponent local defaults', () => { + it('includes the standard admin bootstrap defaults under the unified admin step', () => { + expect(SETUP_STEP_LOCAL_DEFAULTS.admin).toEqual(expect.objectContaining({ + 'authority.provider': 'standard', + 'users.superuser.username': 'admin', + 'users.superuser.email': 'admin@stella-ops.local', + 'users.superuser.password': 'Admin@Stella1', + })); + }); + + it('keeps the legacy authority and users defaults for compatibility', () => { + expect(SETUP_STEP_LOCAL_DEFAULTS.authority).toEqual(expect.objectContaining({ + 'authority.provider': 'standard', + })); + expect(SETUP_STEP_LOCAL_DEFAULTS.users).toEqual(expect.objectContaining({ + 'users.superuser.username': 'admin', + })); + }); + + it('merges admin defaults without overwriting explicit operator input', () => { + expect(mergeSetupStepLocalDefaults('admin', { + 'users.superuser.username': 'custom-admin', + 'users.superuser.email': '', + })).toEqual(expect.objectContaining({ + 'authority.provider': 'standard', + 'users.superuser.username': 'custom-admin', + 'users.superuser.email': 'admin@stella-ops.local', + 'users.superuser.password': 'Admin@Stella1', + })); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.spec.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.spec.ts index dcdce10bd..60ea02084 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.spec.ts @@ -60,6 +60,42 @@ describe('SetupWizardStateService', () => { expect(service.steps().find((step) => step.id === 'cache')?.status).toBe('in_progress'); }); + it('preserves locally entered sensitive values when backend session config is sanitized', () => { + service.setConfigValues({ + 'users.superuser.password': 'Admin@Stella1', + 'database.host': 'old-host', + }); + + const session: SetupSession = { + sessionId: 'session-sensitive', + scopeKey: 'installation', + status: 'in_progress', + startedAt: '2026-04-14T00:00:00Z', + definitionVersion: 'v1', + configValues: { + 'database.host': 'db.stella-ops.local', + }, + currentStep: 'admin', + steps: [ + { + stepId: 'database', + status: 'completed', + }, + { + stepId: 'admin', + status: 'in_progress', + }, + ], + completedSteps: ['database'], + skippedSteps: [], + }; + + service.initializeSession(session); + + expect(service.configValues()['database.host']).toBe('db.stella-ops.local'); + expect(service.configValues()['users.superuser.password']).toBe('Admin@Stella1'); + }); + it('falls back to the first pending step when the backend session has no current step', () => { const session: SetupSession = { sessionId: 'session-2', @@ -80,7 +116,7 @@ describe('SetupWizardStateService', () => { service.initializeSession(session); - expect(service.currentStepId()).toBe('cache'); + expect(service.currentStepId()).toBe('welcome'); }); it('navigates backward without returning to welcome', () => { @@ -126,6 +162,33 @@ describe('SetupWizardStateService', () => { expect(service.progressPercent()).toBe(83); }); + it('allows finalization from the last required step before it is applied', () => { + const session: SetupSession = { + sessionId: 'session-3', + scopeKey: 'installation', + status: 'in_progress', + startedAt: '2026-04-14T00:00:00Z', + definitionVersion: 'v1', + configValues: {}, + currentStep: 'crypto', + steps: [ + { stepId: 'database', status: 'completed' }, + { stepId: 'cache', status: 'completed' }, + { stepId: 'migrations', status: 'completed' }, + { stepId: 'admin', status: 'completed' }, + { stepId: 'crypto', status: 'in_progress' }, + ], + completedSteps: ['database', 'cache', 'migrations', 'admin'], + skippedSteps: [], + }; + + service.initializeSession(session); + + expect(service.currentStepId()).toBe('crypto'); + expect(service.allRequiredComplete()).toBeFalse(); + expect(service.navigation().canComplete).toBeTrue(); + }); + it('never exposes a skippable current step in the truthful wizard flow', () => { service.currentStepId.set('database'); expect(service.canSkipCurrentStep()).toBeFalse(); diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.ts index 8f83fc0a0..5c0ab503c 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/services/setup-wizard-state.service.ts @@ -179,7 +179,7 @@ export class SetupWizardStateService { currentStepIndex: index, canGoBack: index > 0, canGoNext: index < ordered.length - 1 && this.canProceedFromCurrentStep(), - canComplete: this.allRequiredComplete(), + canComplete: this.canCompleteFromCurrentStep(), }; }); @@ -250,7 +250,7 @@ export class SetupWizardStateService { */ initializeSession(session: SetupSession): void { this.session.set(session); - this.configValues.set({ ...session.configValues }); + this.configValues.set(this.mergeSensitiveConfigValues(this.configValues(), session.configValues)); const stepStates = new Map(session.steps.map(step => [step.stepId, step])); this.steps.update(steps => @@ -354,6 +354,38 @@ export class SetupWizardStateService { })); } + private mergeSensitiveConfigValues( + current: Record, + next: Record, + ): Record { + const merged = { ...next }; + for (const [key, value] of Object.entries(current)) { + if (!this.isSensitiveKey(key)) { + continue; + } + + if (typeof merged[key] === 'string' && merged[key].trim().length > 0) { + continue; + } + + if (typeof value === 'string' && value.trim().length > 0) { + merged[key] = value; + } + } + + return merged; + } + + private isSensitiveKey(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'); + } + /** * Update step status */ @@ -567,6 +599,26 @@ export class SetupWizardStateService { return step.status === 'completed' || step.status === 'skipped' || step.isSkippable; } + private canCompleteFromCurrentStep(): boolean { + if (this.allRequiredComplete()) { + return true; + } + + const step = this.currentStep(); + if (!step) { + return false; + } + + const ordered = this.orderedSteps(); + const index = this.currentStepIndex(); + if (index !== ordered.length - 1 || !this.dependenciesMet()) { + return false; + } + + const pendingRequired = this.pendingRequiredSteps(); + return pendingRequired.length === 1 && pendingRequired[0].id === step.id; + } + private mapSessionStepStatus( backendStatus: SetupSession['steps'][number]['status'] | undefined, currentStep: SetupStepId | undefined, diff --git a/src/Web/StellaOps.Web/tsconfig.spec.active-surfaces.json b/src/Web/StellaOps.Web/tsconfig.spec.active-surfaces.json index c90af4353..173b7f093 100644 --- a/src/Web/StellaOps.Web/tsconfig.spec.active-surfaces.json +++ b/src/Web/StellaOps.Web/tsconfig.spec.active-surfaces.json @@ -8,6 +8,8 @@ }, "files": [ "src/test-setup.ts", + "src/app/features/setup-wizard/components/setup-wizard.component.spec.ts", + "src/app/features/setup-wizard/services/setup-wizard-state.service.spec.ts", "src/app/features/integration-hub/integration.service.spec.ts", "src/app/features/integrations/integration-wizard.component.spec.ts", "src/tests/deployments/create-deployment.component.spec.ts",