diff --git a/docs/implplan/SPRINT_20260422_003_Concelier_source_credential_entry_paths.md b/docs-archived/implplan/SPRINT_20260422_003_Concelier_source_credential_entry_paths.md similarity index 87% rename from docs/implplan/SPRINT_20260422_003_Concelier_source_credential_entry_paths.md rename to docs-archived/implplan/SPRINT_20260422_003_Concelier_source_credential_entry_paths.md index a017e80e0..451d9565b 100644 --- a/docs/implplan/SPRINT_20260422_003_Concelier_source_credential_entry_paths.md +++ b/docs-archived/implplan/SPRINT_20260422_003_Concelier_source_credential_entry_paths.md @@ -87,7 +87,7 @@ Completion criteria: - [x] Adobe/Chromium source docs/runtime verification is rechecked after the credential-path rollout. ### SRC-CREDS-005 - Surface blocked schedule state for credential-gated sources -Status: DOING +Status: DONE Dependency: SRC-CREDS-002 Owners: Developer / Implementer, Documentation author Task description: @@ -95,9 +95,9 @@ Task description: - Sync attempts for blocked sources must explain that credentials or required URIs are missing instead of looking like a generic scheduler failure. The source-management API, focused tests, and operator docs all need to align on the blocked-state contract. Completion criteria: -- [ ] Source status responses preserve persisted enablement while exposing an explicit blocked readiness state and reason. -- [ ] Sync attempts for blocked sources report a blocked outcome with the missing-configuration reason attached. -- [ ] Docs explain the blocked or sleeping state for credential-gated sources. +- [x] Source status responses preserve persisted enablement while exposing an explicit blocked readiness state and reason. +- [x] Sync attempts for blocked sources report a blocked outcome with the missing-configuration reason attached. +- [x] Docs explain the blocked or sleeping state for credential-gated sources. ## Execution Log | Date (UTC) | Update | Owner | @@ -106,6 +106,8 @@ Completion criteria: | 2026-04-22 | Added persisted source-configuration schemas and runtime overlays for GHSA, Cisco, Microsoft, Oracle, Adobe, and Chromium so source settings can be supplied through Concelier rather than only through host env/yaml. | Codex | | 2026-04-22 | Updated Web and CLI operator surfaces plus Concelier/CLI/UI documentation with login destinations, credential types, retained-secret behavior, and Adobe/Chromium public-endpoint guidance. | Codex | | 2026-04-22 | Verification: `dotnet build src/Concelier/StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj -v minimal` succeeded; targeted xUnit helper run for 4 source-configuration methods passed (`Total: 4, Failed: 0`); `npm run build` in `src/Web/StellaOps.Web` succeeded. | Codex | +| 2026-04-22 | Added blocked or sleeping source-state handling so persisted enablement survives missing credentials, sync attempts report blocked outcomes, targeted xUnit blocked-state coverage passed (`Total: 5, Failed: 0`), and the Web source catalog now renders blocked sources explicitly. | Codex | +| 2026-04-22 | Formalised the readiness contract: added `SourceReadiness` constants + explicit `readiness` and `blockedReason` fields on `/status`, `/{id}/enable`, `/{id}/sync`, and `/sync` responses alongside the pre-existing `syncState`/`blockingReason` surface; authored two new focused regression tests (`StatusEndpoint_ExposesReadinessAliasAndBlockedReasonAlongsideSyncState`, `StatusEndpoint_TransitionsFromBlockedToReadyWhenCredentialsPersisted`, `SyncEndpoint_ConnectorNotInvokedWhenBlocked`); added a Blocked / sleeping section to `docs/modules/concelier/connectors.md` and a cross-link in `docs/modules/cli/guides/commands/db.md`. Targeted xUnit run across 8 readiness/sync blocked tests passed (`Total: 8, Failed: 0`); regression run across the 4 SRC-CREDS-002 source-configuration tests still passes (`Total: 4, Failed: 0`). | Codex | ## Decisions & Risks - Decision: source credentials must be operator-supplied through StellaOps UI and CLI paths, with environment variables retained only as backward-compatible fallbacks. @@ -113,6 +115,7 @@ Completion criteria: - Risk: some upstream programs may require vendor accounts, approval, or terms acceptance even if StellaOps supports the connector path; docs must distinguish product integration support from upstream entitlement. - Decision: Adobe and Chromium now expose the same persisted UI/CLI configuration path as the credentialed connectors so mirrored public endpoints are no longer env-only overrides. - Decision: `additionalIndexUris` is normalized like the other multi-URI fields, so CLI and UI comma or semicolon input converges to a stable persisted shape. +- Decision: persisted source `enabled` now represents operator intent, while source status exposes `syncState` and `blockingReason` so credential-gated connectors can remain enabled but blocked until credentials or required URIs are supplied. - Web fetch audit (user-requested upstream credential research): - `https://docs.github.com/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token` - confirm current GitHub PAT creation path and policy notes. - `https://docs.github.com/en/enterprise-cloud@latest/rest/security-advisories/global-advisories` - confirm GHSA REST authentication expectations and anonymous/fine-grained token support. diff --git a/docs/modules/cli/guides/commands/db.md b/docs/modules/cli/guides/commands/db.md index 14ff91f01..bd9a0a943 100644 --- a/docs/modules/cli/guides/commands/db.md +++ b/docs/modules/cli/guides/commands/db.md @@ -55,6 +55,12 @@ Notes: - Multi-value URI fields accept comma-, semicolon-, or newline-separated absolute URIs. - The current CLI path sends literal values on the command line. Use the Web UI path if command-history exposure is unacceptable for a secret. +Blocked state for credential-gated sources: + +- Persisted enablement (`enabled=true`) is kept separate from runtime readiness. When an enabled source is missing required credentials or URIs, its `readiness` (alias `syncState`) is `blocked`, `blockedReason` describes what is missing, and both `/sync` and the batch `/sync` paths skip it with an explicit `blocked` outcome instead of invoking the connector and emitting a misleading scheduler failure. +- Supplying the missing field through `stella db connectors configure --set =` flips the source to `readiness=ready` on the next status call without any disable/re-enable step. +- See [connectors.md -> Blocked / sleeping readiness state](/C:/dev/New%20folder/git.stella-ops.org/docs/modules/concelier/connectors.md) for the full endpoint contract. + ### db fetch Trigger a connector stage (`fetch`, `parse`, or `map`) for a given source. diff --git a/docs/modules/concelier/connectors.md b/docs/modules/concelier/connectors.md index 40db04ea6..d0ed565cf 100644 --- a/docs/modules/concelier/connectors.md +++ b/docs/modules/concelier/connectors.md @@ -9,6 +9,50 @@ Operator configuration note: - Oracle, Adobe, and Chromium use public defaults and only need UI or CLI input when you override or mirror the upstream endpoints. - See [source-credentials.md](docs/modules/concelier/operations/source-credentials.md). +--- + +## Blocked / sleeping readiness state + +Each advisory source has two independent flags in its status response: + +| Field | Meaning | +| --- | --- | +| `enabled` | Persisted operator intent. `true` means "the operator asked for this source to run". Survives restarts, backfills, and connectivity checks. | +| `readiness` | Runtime readiness. One of `ready`, `blocked`, `disabled`, or `unsupported`. Computed live from connector configuration. | + +The `blocked` state is reserved for **credential-gated or URI-gated sources that are persisted-enabled but missing required configuration**. In this state: + +- `enabled` remains `true` — the operator's intent is preserved across restarts. +- `readiness` (alias `syncState`) is `blocked`. +- `blockedReason` is a free-form human-readable message naming the missing field(s) (for example, `"GitHub Security Advisories requires an API token before sync can run."`). +- `blockingReason` carries the structured diagnostics object: `errorCode = SOURCE_CONFIG_REQUIRED`, `possibleReasons`, and ordered `remediationSteps`. +- The scheduler and the manual `/sync` and `/sync-all` endpoints **short-circuit** — the connector is never invoked, so the operator does not see a generic scheduler failure or a misleading "last run succeeded" state. + +### Endpoint-by-endpoint contract + +| Endpoint | Blocked behaviour | +| --- | --- | +| `GET /api/v1/advisory-sources/status` | Per-source `readiness = "blocked"`, `blockedReason` populated, `readyForSync = false`, `enabled = true`. | +| `POST /api/v1/advisory-sources/{sourceId}/enable` | Returns `200 OK` with `{ enabled: true, readiness: "blocked", blockingReason, blockedReason }`. The persisted row is enabled but the source registry is left disabled until credentials land. | +| `POST /api/v1/advisory-sources/{sourceId}/sync` | Returns `422 Unprocessable Entity` with `{ error: "source_config_required", readiness: "blocked", code: "SOURCE_CONFIG_REQUIRED", blockedReason }`. The connector is **not** invoked and no job run is created. | +| `POST /api/v1/advisory-sources/sync` | Each blocked source is reported inside `results[]` with `outcome: "blocked"`, `readiness: "blocked"`, `errorCode: "SOURCE_CONFIG_REQUIRED"`, `blockedReason`; it is excluded from `totalTriggered`. Other sources in the batch still run normally. | +| `POST /api/v1/advisory-sources/check` | Blocked sources keep their persisted `enabled` value instead of being auto-disabled by the periodic connectivity check, so the status continues to reflect operator intent until credentials are supplied. | + +### Resolving the blocked state + +The operator resolves a blocked source by supplying the missing configuration through either entry path: + +- Web UI: `Integrations -> Advisory sources`, open the source card, fill in the fields under **Configuration**, save. +- CLI: `stella db connectors configure --set =` (see [`docs/modules/cli/guides/commands/db.md`](/C:/dev/New%20folder/git.stella-ops.org/docs/modules/cli/guides/commands/db.md)). + +On the next `/status` call the source's `readiness` flips to `ready`, `blockedReason` becomes `null`, and `readyForSync` becomes `true`. No disable/re-enable dance is required — the runtime settings cache picks up the persisted change through the options-invalidator and the connector runs on the next scheduler tick or manual trigger. + +UI/CLI rendering guidance: + +- Render `enabled` and `readiness` as two separate indicators. An `enabled` toggle that silently collapses to a "disabled" or "failed" visualisation hides operator intent from the next operator on shift. +- Prefer `blockedReason` (short human sentence) for the visible row and fall back to `blockingReason.possibleReasons` / `remediationSteps` for an expanded drawer. +- Do not treat `blocked` as an error state for alerting purposes — it is an expected "sleeping" state on fresh installs and on hosts that have not yet received the credential set. + The catalog currently contains **78 source definitions** across **14 categories**. The authoritative source list is defined in `src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceDefinitions.cs`. Canonical runtime note: the operator-facing source IDs in this index are the only scheduler/catalog IDs that should be used for Concelier jobs and setup. Legacy connector aliases such as `ics-cisa`, `ics-kaspersky`, `ru-bdu`, `ru-nkcki`, `vndr-adobe`, `vndr-apple`, `vndr-chromium`, `vndr-cisco`, `vndr-oracle`, and `vndr.msrc` remain compatibility-only aliases inside normalization paths and must not appear as primary runtime job keys. diff --git a/src/Concelier/StellaOps.Concelier.WebService/Extensions/SourceManagementEndpointExtensions.cs b/src/Concelier/StellaOps.Concelier.WebService/Extensions/SourceManagementEndpointExtensions.cs index 34c09222f..3d12dcef5 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Extensions/SourceManagementEndpointExtensions.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Extensions/SourceManagementEndpointExtensions.cs @@ -153,7 +153,12 @@ internal static class SourceManagementEndpointExtensions Enabled = source.Enabled, LastCheck = source.LastCheck, SyncSupported = source.SyncSupported, - FetchJobKind = source.FetchJobKind + FetchJobKind = source.FetchJobKind, + SyncState = source.SyncState, + Readiness = source.Readiness, + ReadyForSync = source.ReadyForSync, + BlockingReason = source.BlockingReason, + BlockedReason = source.BlockedReason }) .ToList(); @@ -161,7 +166,7 @@ internal static class SourceManagementEndpointExtensions }) .WithName("GetSourceStatus") .WithSummary("Get status of all sources with last connectivity check") - .WithDescription("Returns enabled/disabled state and last connectivity check result for every registered source.") + .WithDescription("Returns persisted enablement, sync readiness, blocked reasons, and the last connectivity check result for every registered source.") .Produces(StatusCodes.Status200OK) .RequireAuthorization(AdvisoryReadPolicy); @@ -187,21 +192,19 @@ internal static class SourceManagementEndpointExtensions detail: $"Source '{normalizedSourceId}' is cataloged but this host does not register a runnable fetch pipeline for it."); } - if (await configuredSources.GetConfigurationFailureAsync(normalizedSourceId, cancellationToken).ConfigureAwait(false) is { } configurationFailure) - { - return HttpResults.UnprocessableEntity(new - { - error = "source_config_required", - sourceId = normalizedSourceId, - code = configurationFailure.ErrorCode, - message = configurationFailure.ErrorMessage, - reasons = configurationFailure.PossibleReasons, - }); - } - var success = await configuredSources.EnableSourceAsync(normalizedSourceId, cancellationToken: cancellationToken).ConfigureAwait(false); + var blockingReason = await configuredSources.GetConfigurationFailureAsync(normalizedSourceId, cancellationToken).ConfigureAwait(false); + var readiness = blockingReason is null ? SourceReadiness.Ready : SourceReadiness.Blocked; return success - ? HttpResults.Ok(new { sourceId = normalizedSourceId, enabled = true }) + ? HttpResults.Ok(new + { + sourceId = normalizedSourceId, + enabled = true, + readiness, + syncState = readiness, + blockingReason, + blockedReason = blockingReason?.ErrorMessage, + }) : HttpResults.UnprocessableEntity(new { error = "enable_failed", sourceId = normalizedSourceId }); }) .WithName("EnableSource") @@ -308,12 +311,6 @@ internal static class SourceManagementEndpointExtensions continue; } - if (await configuredSources.GetConfigurationFailureAsync(normalizedSourceId, cancellationToken).ConfigureAwait(false) is not null) - { - results.Add(new BatchSourceResultItem { SourceId = normalizedSourceId, Success = false, Error = "source_config_required" }); - continue; - } - var success = await configuredSources.EnableSourceAsync(normalizedSourceId, cancellationToken: cancellationToken).ConfigureAwait(false); results.Add(new BatchSourceResultItem { SourceId = normalizedSourceId, Success = success, Error = success ? null : "enable_failed" }); } @@ -397,6 +394,9 @@ internal static class SourceManagementEndpointExtensions error = "source_config_required", sourceId = normalizedSourceId, code = configurationFailure.ErrorCode, + readiness = SourceReadiness.Blocked, + syncState = SourceReadiness.Blocked, + blockedReason = configurationFailure.ErrorMessage, message = configurationFailure.ErrorMessage, reasons = configurationFailure.PossibleReasons, }); @@ -466,8 +466,11 @@ internal static class SourceManagementEndpointExtensions { sourceId, jobKind = configuredSources.GetFetchJobKind(sourceId), - outcome = "config_required", + outcome = SourceReadiness.Blocked, + readiness = SourceReadiness.Blocked, + syncState = SourceReadiness.Blocked, errorCode = configurationFailure.ErrorCode, + blockedReason = configurationFailure.ErrorMessage, message = configurationFailure.ErrorMessage, }); continue; @@ -672,10 +675,32 @@ public sealed record SourceStatusResponse public sealed record SourceStatusItem { public string SourceId { get; init; } = string.Empty; + + /// Persisted operator intent: operator asked for this source to run. public bool Enabled { get; init; } + public SourceConnectivityResult? LastCheck { get; init; } public bool SyncSupported { get; init; } public string FetchJobKind { get; init; } = string.Empty; + + /// + /// Runtime readiness. One of , , + /// , or . + /// Independent from : a persisted-enabled source that is missing credentials + /// or required URIs is rather than silently failing. + /// + public string Readiness { get; init; } = SourceReadiness.Disabled; + + /// Backwards-compatible alias for . + public string SyncState { get; init; } = SourceReadiness.Disabled; + + public bool ReadyForSync { get; init; } + + /// Structured diagnostics object when is blocked or unsupported. + public SourceConnectivityResult? BlockingReason { get; init; } + + /// Free-form human-readable reason; null when is ready. + public string? BlockedReason { get; init; } } public sealed record BatchSourceRequest diff --git a/src/Concelier/StellaOps.Concelier.WebService/Services/ConfiguredAdvisorySourceService.cs b/src/Concelier/StellaOps.Concelier.WebService/Services/ConfiguredAdvisorySourceService.cs index 46630087a..ec201bcaa 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Services/ConfiguredAdvisorySourceService.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Services/ConfiguredAdvisorySourceService.cs @@ -29,6 +29,7 @@ public sealed class ConfiguredAdvisorySourceService private const string MirrorSourceId = "stella-mirror"; private const string MirrorConsumerHttpClientName = "MirrorConsumer"; private const string MirrorIndexPath = "/concelier/exports/index.json"; + private const string SourceConfigRequiredErrorCode = "SOURCE_CONFIG_REQUIRED"; private static readonly TimeSpan SetupConnectivityTimeout = TimeSpan.FromSeconds(10); private readonly ISourceRepository sourceRepository; @@ -93,16 +94,24 @@ public sealed class ConfiguredAdvisorySourceService return sourceRegistry.GetAllSources() .Select(source => { - var readinessFailure = GetConfigurationFailureCore(source.Id); + var enabled = enabledByKey.TryGetValue(source.Id, out var persistedEnabled) && persistedEnabled; + var syncSupported = IsSourceRunnable(source.Id); + var blockingReason = enabled + ? GetSyncBlockingReasonCore(source.Id, syncSupported) + : null; + + var syncState = GetSyncState(enabled, syncSupported, blockingReason); return new ConfiguredAdvisorySourceStatus( source.Id, - enabledByKey.TryGetValue(source.Id, out var enabled) && - enabled && - IsSourceRunnable(source.Id) && - readinessFailure is null, - readinessFailure ?? sourceRegistry.GetLastCheckResult(source.Id), - IsSourceRunnable(source.Id), - BuildFetchJobKind(source.Id)); + enabled, + sourceRegistry.GetLastCheckResult(source.Id) ?? blockingReason, + syncSupported, + BuildFetchJobKind(source.Id), + syncState, + enabled && syncSupported && blockingReason is null, + blockingReason, + syncState, + blockingReason?.ErrorMessage); }) .ToImmutableArray(); } @@ -212,6 +221,39 @@ public sealed class ConfiguredAdvisorySourceService private bool IsSourceReadyForSyncCore(string sourceId) => IsSourceRunnable(sourceId) && GetConfigurationFailureCore(sourceId) is null; + private SourceConnectivityResult? GetSyncBlockingReasonCore(string sourceId, bool syncSupported) + { + if (!syncSupported) + { + var definition = sourceRegistry.GetSource(sourceId); + return definition is null + ? null + : CreateUnsupportedResult(sourceId, definition.DocumentationUrl); + } + + return GetConfigurationFailureCore(sourceId); + } + + private static string GetSyncState(bool enabled, bool syncSupported, SourceConnectivityResult? blockingReason) + { + if (!enabled) + { + return "disabled"; + } + + if (!syncSupported) + { + return "unsupported"; + } + + if (blockingReason is not null) + { + return "blocked"; + } + + return "ready"; + } + private SourceConnectivityResult? GetConfigurationFailureCore(string sourceId) { var normalizedSourceId = NormalizeSourceId(sourceId); @@ -309,14 +351,7 @@ public sealed class ConfiguredAdvisorySourceService normalizedSourceId); return false; } - - if (GetConfigurationFailureCore(normalizedSourceId) is not null) - { - logger.LogWarning( - "Rejected enable request for advisory source {SourceId} because required connector configuration is missing.", - normalizedSourceId); - return false; - } + var configurationFailure = GetConfigurationFailureCore(normalizedSourceId); var existing = await FindPersistedSourceAsync(normalizedSourceId, cancellationToken).ConfigureAwait(false); var mirrorUrl = string.Equals(normalizedSourceId, MirrorSourceId, StringComparison.OrdinalIgnoreCase) @@ -337,6 +372,15 @@ public sealed class ConfiguredAdvisorySourceService await ApplyMirrorConsumerConfigurationAsync(mirrorUrl!, cancellationToken).ConfigureAwait(false); } + if (configurationFailure is not null) + { + await sourceRegistry.DisableSourceAsync(normalizedSourceId, cancellationToken).ConfigureAwait(false); + logger.LogInformation( + "Enabled advisory source {SourceId} in blocked state because required connector configuration is still missing.", + normalizedSourceId); + return true; + } + await sourceRegistry.EnableSourceAsync(normalizedSourceId, cancellationToken).ConfigureAwait(false); return true; } @@ -417,9 +461,15 @@ public sealed class ConfiguredAdvisorySourceService : CreateUnsupportedResult(definition.Id, definition.DocumentationUrl); results.Add(result); var existing = await FindPersistedSourceAsync(definition.Id, cancellationToken).ConfigureAwait(false); + var enabled = result.IsHealthy && IsSourceReadyForSyncCore(definition.Id); + if (IsConfigurationBlocked(result)) + { + enabled = existing?.Enabled ?? false; + } + await UpsertSourceAsync( definition, - enabled: result.IsHealthy && IsSourceReadyForSyncCore(definition.Id), + enabled, existing, configValues: null, mode: string.Equals(definition.Id, MirrorSourceId, StringComparison.OrdinalIgnoreCase) ? MirrorMode : ManualMode, @@ -1142,7 +1192,7 @@ public sealed class ConfiguredAdvisorySourceService string? documentationUrl) => SourceConnectivityResult.Failed( sourceId, - "SOURCE_CONFIG_REQUIRED", + SourceConfigRequiredErrorCode, message, possibleReasons, remediationSteps, @@ -1394,6 +1444,9 @@ public sealed class ConfiguredAdvisorySourceService .Add("fetchJobKind", BuildFetchJobKind(sourceId)) }; + private static bool IsConfigurationBlocked(SourceConnectivityResult result) + => string.Equals(result.ErrorCode, SourceConfigRequiredErrorCode, StringComparison.OrdinalIgnoreCase); + private sealed record SetupAdvisorySourceInstruction( string Mode, ImmutableArray EnabledSourceIds, @@ -1405,12 +1458,45 @@ public sealed class ConfiguredAdvisorySourceService string Message); } +/// +/// Runtime status of an advisory source. reflects persisted +/// operator intent while (alias ) captures +/// runtime readiness. When a persisted-enabled source lacks required credentials/URIs, +/// is , +/// contains the missing-configuration message, and carries the +/// full structured diagnostics object. +/// public sealed record ConfiguredAdvisorySourceStatus( string SourceId, bool Enabled, SourceConnectivityResult? LastCheck, bool SyncSupported, - string FetchJobKind); + string FetchJobKind, + string SyncState, + bool ReadyForSync, + SourceConnectivityResult? BlockingReason, + string Readiness, + string? BlockedReason); + +/// +/// Runtime readiness states for advisory sources. Independent from persisted +/// enabled: a source can be persisted-enabled yet while +/// credentials or required URIs are missing. +/// +public static class SourceReadiness +{ + /// Not persisted-enabled; no sync will be attempted. + public const string Disabled = "disabled"; + + /// Cataloged but this host does not register a runnable fetch job. + public const string Unsupported = "unsupported"; + + /// Persisted-enabled but missing required configuration (credentials or URIs). + public const string Blocked = "blocked"; + + /// Persisted-enabled and configured; sync runs on schedule or on trigger. + public const string Ready = "ready"; +} public sealed record SetupAdvisorySourcesBootstrapRequest( IReadOnlyDictionary? ConfigValues); diff --git a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/AdvisorySourceEndpointsTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/AdvisorySourceEndpointsTests.cs index 614863d77..786f4920b 100644 --- a/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/AdvisorySourceEndpointsTests.cs +++ b/src/Concelier/__Tests/StellaOps.Concelier.WebService.Tests/AdvisorySourceEndpointsTests.cs @@ -718,6 +718,43 @@ public sealed class AdvisorySourceEndpointsTests : IClassFixture().UpsertAsync(new SourceEntity + { + Id = Guid.Parse("9e650e22-746f-4bc6-9a4f-c7dce2676e10"), + Key = "ghsa", + Name = "GHSA", + SourceType = "ghsa", + Url = "https://github.com/advisories", + Priority = 80, + Enabled = true, + Config = "{}", + Metadata = "{}", + CreatedAt = DateTimeOffset.Parse("2026-02-19T00:00:00Z"), + UpdatedAt = DateTimeOffset.Parse("2026-02-19T00:00:00Z") + }, CancellationToken.None); + + using var client = CreateTenantClient(); + + var response = await client.GetAsync("/api/v1/advisory-sources/status", CancellationToken.None); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken: CancellationToken.None); + Assert.NotNull(payload); + + var ghsa = Assert.Single(payload!.Sources.Where(source => source.SourceId == "ghsa")); + Assert.True(ghsa.Enabled); + Assert.True(ghsa.SyncSupported); + Assert.False(ghsa.ReadyForSync); + Assert.Equal("blocked", ghsa.SyncState); + Assert.NotNull(ghsa.BlockingReason); + Assert.Equal("SOURCE_CONFIG_REQUIRED", ghsa.BlockingReason!.ErrorCode); + } + [Trait("Category", TestCategories.Unit)] [Fact] public async Task CatalogEndpoint_MarksCatalogOnlySourceAsUnsupported() @@ -950,15 +987,24 @@ public sealed class AdvisorySourceEndpointsTests : IClassFixture result.GetProperty("sourceId").GetString() == "ghsa")); - Assert.Equal("config_required", ghsa.GetProperty("outcome").GetString()); + Assert.Equal("blocked", ghsa.GetProperty("outcome").GetString()); + Assert.Equal("blocked", ghsa.GetProperty("syncState").GetString()); Assert.Equal("SOURCE_CONFIG_REQUIRED", ghsa.GetProperty("errorCode").GetString()); } + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task CheckAllEndpoint_PreservesEnabledBlockedSource() + { + _factory.ResetState(); + await _factory.Services.GetRequiredService().UpsertAsync(new SourceEntity + { + Id = Guid.Parse("f8f54c69-592d-429e-9653-4f7b4d48161e"), + Key = "ghsa", + Name = "GHSA", + SourceType = "ghsa", + Url = "https://github.com/advisories", + Priority = 80, + Enabled = true, + Config = "{}", + Metadata = "{}", + CreatedAt = DateTimeOffset.Parse("2026-02-19T00:00:00Z"), + UpdatedAt = DateTimeOffset.Parse("2026-02-19T00:00:00Z") + }, CancellationToken.None); + + using var client = CreateTenantClient(); + + var checkResponse = await client.PostAsync("/api/v1/advisory-sources/check", content: null, CancellationToken.None); + checkResponse.EnsureSuccessStatusCode(); + + var statusResponse = await client.GetAsync("/api/v1/advisory-sources/status", CancellationToken.None); + statusResponse.EnsureSuccessStatusCode(); + + var payload = await statusResponse.Content.ReadFromJsonAsync(cancellationToken: CancellationToken.None); + Assert.NotNull(payload); + + var ghsa = Assert.Single(payload!.Sources.Where(source => source.SourceId == "ghsa")); + Assert.True(ghsa.Enabled); + Assert.Equal("blocked", ghsa.SyncState); + Assert.False(ghsa.ReadyForSync); + Assert.NotNull(ghsa.BlockingReason); + Assert.Equal("SOURCE_CONFIG_REQUIRED", ghsa.BlockingReason!.ErrorCode); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task StatusEndpoint_ExposesReadinessAliasAndBlockedReasonAlongsideSyncState() + { + _factory.ResetState(); + await _factory.Services.GetRequiredService().UpsertAsync(new SourceEntity + { + Id = Guid.Parse("8c7dd7cb-b6c1-49e2-8a14-3c8b6f2f4f71"), + Key = "ghsa", + Name = "GHSA", + SourceType = "ghsa", + Url = "https://github.com/advisories", + Priority = 80, + Enabled = true, + Config = "{}", + Metadata = "{}", + CreatedAt = DateTimeOffset.Parse("2026-02-19T00:00:00Z"), + UpdatedAt = DateTimeOffset.Parse("2026-02-19T00:00:00Z") + }, CancellationToken.None); + + using var client = CreateTenantClient(); + + var response = await client.GetAsync("/api/v1/advisory-sources/status", CancellationToken.None); + response.EnsureSuccessStatusCode(); + + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(CancellationToken.None), cancellationToken: CancellationToken.None); + var ghsa = document.RootElement.GetProperty("sources").EnumerateArray() + .Single(s => s.GetProperty("sourceId").GetString() == "ghsa"); + + // Persisted enablement is preserved. + Assert.True(ghsa.GetProperty("enabled").GetBoolean()); + // Runtime readiness is exposed as an explicit blocked state separate from enablement. + Assert.Equal("blocked", ghsa.GetProperty("readiness").GetString()); + Assert.Equal("blocked", ghsa.GetProperty("syncState").GetString()); + Assert.False(ghsa.GetProperty("readyForSync").GetBoolean()); + // Free-form reason describes what is missing without forcing clients to parse the structured diagnostics. + Assert.False(string.IsNullOrWhiteSpace(ghsa.GetProperty("blockedReason").GetString())); + Assert.Equal( + "GitHub Security Advisories requires an API token before sync can run.", + ghsa.GetProperty("blockedReason").GetString()); + Assert.Equal("SOURCE_CONFIG_REQUIRED", ghsa.GetProperty("blockingReason").GetProperty("errorCode").GetString()); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task StatusEndpoint_TransitionsFromBlockedToReadyWhenCredentialsPersisted() + { + _factory.ResetState(); + _factory.ResetTriggeredJobs(); + await _factory.Services.GetRequiredService().UpsertAsync(new SourceEntity + { + Id = Guid.Parse("2a7b6a3f-4f21-42a8-9eb6-bd2d3a5df6f9"), + Key = "ghsa", + Name = "GHSA", + SourceType = "ghsa", + Url = "https://github.com/advisories", + Priority = 80, + Enabled = true, + Config = "{}", + Metadata = "{}", + CreatedAt = DateTimeOffset.Parse("2026-02-19T00:00:00Z"), + UpdatedAt = DateTimeOffset.Parse("2026-02-19T00:00:00Z") + }, CancellationToken.None); + + using var client = CreateTenantClient(); + + // 1) Missing credential -> readiness is blocked. + var beforeResponse = await client.GetAsync("/api/v1/advisory-sources/status", CancellationToken.None); + beforeResponse.EnsureSuccessStatusCode(); + var beforePayload = await beforeResponse.Content.ReadFromJsonAsync(cancellationToken: CancellationToken.None); + Assert.NotNull(beforePayload); + var ghsaBefore = Assert.Single(beforePayload!.Sources.Where(source => source.SourceId == "ghsa")); + Assert.True(ghsaBefore.Enabled); + Assert.Equal("blocked", ghsaBefore.Readiness); + Assert.False(ghsaBefore.ReadyForSync); + + // 2) Operator supplies the missing credential through the persisted-config API. + var updateResponse = await client.PutAsJsonAsync( + "/api/v1/advisory-sources/ghsa/configuration", + new SourceConfigurationUpdateRequest + { + Values = new Dictionary + { + ["apiToken"] = "ghp_transition_test_token", + }, + }, + CancellationToken.None); + updateResponse.EnsureSuccessStatusCode(); + + // 3) Next /status call flips readiness to ready without any disable/re-enable dance. + var afterResponse = await client.GetAsync("/api/v1/advisory-sources/status", CancellationToken.None); + afterResponse.EnsureSuccessStatusCode(); + var afterPayload = await afterResponse.Content.ReadFromJsonAsync(cancellationToken: CancellationToken.None); + Assert.NotNull(afterPayload); + var ghsaAfter = Assert.Single(afterPayload!.Sources.Where(source => source.SourceId == "ghsa")); + Assert.True(ghsaAfter.Enabled); + Assert.Equal("ready", ghsaAfter.Readiness); + Assert.Equal("ready", ghsaAfter.SyncState); + Assert.True(ghsaAfter.ReadyForSync); + Assert.Null(ghsaAfter.BlockedReason); + } + + [Trait("Category", TestCategories.Unit)] + [Fact] + public async Task SyncEndpoint_ConnectorNotInvokedWhenBlocked() + { + _factory.ResetState(); + _factory.ResetTriggeredJobs(); + await _factory.Services.GetRequiredService().UpsertAsync(new SourceEntity + { + Id = Guid.Parse("5a0b3f6b-5a95-4f0a-9f9e-70f0e7a0c4a2"), + Key = "ghsa", + Name = "GHSA", + SourceType = "ghsa", + Url = "https://github.com/advisories", + Priority = 80, + Enabled = true, + Config = "{}", + Metadata = "{}", + CreatedAt = DateTimeOffset.Parse("2026-02-19T00:00:00Z"), + UpdatedAt = DateTimeOffset.Parse("2026-02-19T00:00:00Z") + }, CancellationToken.None); + + using var client = CreateTenantClient(); + + var response = await client.PostAsync("/api/v1/advisory-sources/ghsa/sync", content: null, CancellationToken.None); + + // Blocked sync attempts return 422 with a blocked outcome and the missing-configuration reason. + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + using var payload = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(CancellationToken.None), cancellationToken: CancellationToken.None); + Assert.Equal("blocked", payload.RootElement.GetProperty("readiness").GetString()); + Assert.Equal("SOURCE_CONFIG_REQUIRED", payload.RootElement.GetProperty("code").GetString()); + Assert.Equal( + "GitHub Security Advisories requires an API token before sync can run.", + payload.RootElement.GetProperty("blockedReason").GetString()); + + // The connector is NOT invoked and the job coordinator records no trigger. + Assert.Null(_factory.LastTriggeredKind); + } + [Trait("Category", TestCategories.Unit)] [Fact] public async Task EnableEndpoint_PersistsMirrorAndTriggersInitialAggregation()