audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration

This commit is contained in:
master
2026-01-14 10:48:00 +02:00
parent d7be6ba34b
commit 95d5898650
379 changed files with 40695 additions and 19041 deletions

View File

@@ -0,0 +1,575 @@
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace StellaOps.AdvisoryAI.Inference.LlmProviders;
/// <summary>
/// Google Gemini LLM provider configuration (maps to gemini.yaml).
/// </summary>
public sealed class GeminiConfig : LlmProviderConfigBase
{
/// <summary>
/// API key (or use GEMINI_API_KEY or GOOGLE_API_KEY env var).
/// </summary>
public string? ApiKey { get; set; }
/// <summary>
/// Base URL for API requests.
/// </summary>
public string BaseUrl { get; set; } = "https://generativelanguage.googleapis.com/v1beta";
/// <summary>
/// Model name.
/// </summary>
public string Model { get; set; } = "gemini-1.5-flash";
/// <summary>
/// Fallback models.
/// </summary>
public List<string> FallbackModels { get; set; } = new();
/// <summary>
/// Top-p sampling.
/// </summary>
public double TopP { get; set; } = 1.0;
/// <summary>
/// Top-k sampling.
/// </summary>
public int TopK { get; set; } = 40;
/// <summary>
/// Log request/response bodies.
/// </summary>
public bool LogBodies { get; set; } = false;
/// <summary>
/// Log token usage.
/// </summary>
public bool LogUsage { get; set; } = true;
/// <summary>
/// Bind configuration from IConfiguration.
/// </summary>
public static GeminiConfig FromConfiguration(IConfiguration config)
{
var result = new GeminiConfig();
// Provider section
result.Enabled = config.GetValue("enabled", true);
result.Priority = config.GetValue("priority", 100);
// API section
var api = config.GetSection("api");
result.ApiKey = ExpandEnvVar(api.GetValue<string>("apiKey"));
result.BaseUrl = api.GetValue("baseUrl", "https://generativelanguage.googleapis.com/v1beta")!;
// Model section
var model = config.GetSection("model");
result.Model = model.GetValue("name", "gemini-1.5-flash")!;
result.FallbackModels = model.GetSection("fallbacks").Get<List<string>>() ?? new();
// Inference section
var inference = config.GetSection("inference");
result.Temperature = inference.GetValue("temperature", 0.0);
result.MaxTokens = inference.GetValue("maxTokens", 8192);
result.Seed = inference.GetValue<int?>("seed");
result.TopP = inference.GetValue("topP", 1.0);
result.TopK = inference.GetValue("topK", 40);
// Request section
var request = config.GetSection("request");
result.Timeout = request.GetValue("timeout", TimeSpan.FromSeconds(120));
result.MaxRetries = request.GetValue("maxRetries", 3);
// Logging section
var logging = config.GetSection("logging");
result.LogBodies = logging.GetValue("logBodies", false);
result.LogUsage = logging.GetValue("logUsage", true);
return result;
}
private static string? ExpandEnvVar(string? value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
// Expand ${VAR_NAME} pattern
if (value.StartsWith("${") && value.EndsWith("}"))
{
var varName = value.Substring(2, value.Length - 3);
return Environment.GetEnvironmentVariable(varName);
}
return Environment.ExpandEnvironmentVariables(value);
}
}
/// <summary>
/// Google Gemini LLM provider plugin.
/// </summary>
public sealed class GeminiLlmProviderPlugin : ILlmProviderPlugin
{
public string Name => "Gemini LLM Provider";
public string ProviderId => "gemini";
public string DisplayName => "Google Gemini";
public string Description => "Google Gemini models via Generative Language API";
public string DefaultConfigFileName => "gemini.yaml";
public bool IsAvailable(IServiceProvider services)
{
// Plugin is always available if the assembly is loaded
return true;
}
public ILlmProvider Create(IServiceProvider services, IConfiguration configuration)
{
var config = GeminiConfig.FromConfiguration(configuration);
var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
return new GeminiLlmProvider(
httpClientFactory.CreateClient("Gemini"),
config,
loggerFactory.CreateLogger<GeminiLlmProvider>());
}
public LlmProviderConfigValidation ValidateConfiguration(IConfiguration configuration)
{
var errors = new List<string>();
var warnings = new List<string>();
var config = GeminiConfig.FromConfiguration(configuration);
if (!config.Enabled)
{
return LlmProviderConfigValidation.WithWarnings("Provider is disabled");
}
// Check API key
var apiKey = config.ApiKey
?? Environment.GetEnvironmentVariable("GEMINI_API_KEY")
?? Environment.GetEnvironmentVariable("GOOGLE_API_KEY");
if (string.IsNullOrEmpty(apiKey))
{
errors.Add("API key not configured. Set 'api.apiKey' or GEMINI_API_KEY/GOOGLE_API_KEY environment variable.");
}
// Check base URL
if (string.IsNullOrEmpty(config.BaseUrl))
{
errors.Add("Base URL is required.");
}
else if (!Uri.TryCreate(config.BaseUrl, UriKind.Absolute, out _))
{
errors.Add($"Invalid base URL: {config.BaseUrl}");
}
// Check model
if (string.IsNullOrEmpty(config.Model))
{
warnings.Add("No model specified, will use default 'gemini-1.5-flash'.");
}
if (errors.Count > 0)
{
return new LlmProviderConfigValidation
{
IsValid = false,
Errors = errors,
Warnings = warnings
};
}
return new LlmProviderConfigValidation
{
IsValid = true,
Warnings = warnings
};
}
}
/// <summary>
/// Google Gemini LLM provider implementation.
/// </summary>
public sealed class GeminiLlmProvider : ILlmProvider
{
private readonly HttpClient _httpClient;
private readonly GeminiConfig _config;
private readonly ILogger<GeminiLlmProvider> _logger;
private readonly string _apiKey;
private bool _disposed;
public string ProviderId => "gemini";
public GeminiLlmProvider(
HttpClient httpClient,
GeminiConfig config,
ILogger<GeminiLlmProvider> logger)
{
_httpClient = httpClient;
_config = config;
_logger = logger;
_apiKey = config.ApiKey
?? Environment.GetEnvironmentVariable("GEMINI_API_KEY")
?? Environment.GetEnvironmentVariable("GOOGLE_API_KEY")
?? string.Empty;
ConfigureHttpClient();
}
private void ConfigureHttpClient()
{
_httpClient.BaseAddress = new Uri(_config.BaseUrl.TrimEnd('/') + "/");
_httpClient.Timeout = _config.Timeout;
}
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
{
if (!_config.Enabled)
{
return false;
}
if (string.IsNullOrEmpty(_apiKey))
{
return false;
}
try
{
// Test by listing models
var response = await _httpClient.GetAsync($"models?key={_apiKey}", cancellationToken);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Gemini availability check failed");
return false;
}
}
public async Task<LlmCompletionResult> CompleteAsync(
LlmCompletionRequest request,
CancellationToken cancellationToken = default)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var model = request.Model ?? _config.Model;
var temperature = request.Temperature > 0 ? request.Temperature : _config.Temperature;
var maxTokens = request.MaxTokens > 0 ? request.MaxTokens : _config.MaxTokens;
var geminiRequest = new GeminiGenerateRequest
{
Contents = BuildContents(request),
GenerationConfig = new GeminiGenerationConfig
{
Temperature = temperature,
MaxOutputTokens = maxTokens,
TopP = _config.TopP,
TopK = _config.TopK,
StopSequences = request.StopSequences?.ToList()
}
};
// Add system instruction if present
if (!string.IsNullOrEmpty(request.SystemPrompt))
{
geminiRequest.SystemInstruction = new GeminiContent
{
Parts = new List<GeminiPart>
{
new GeminiPart { Text = request.SystemPrompt }
}
};
}
if (_config.LogBodies)
{
_logger.LogDebug("Gemini request: {Request}", JsonSerializer.Serialize(geminiRequest));
}
var endpoint = $"models/{model}:generateContent?key={_apiKey}";
var response = await _httpClient.PostAsJsonAsync(endpoint, geminiRequest, cancellationToken);
response.EnsureSuccessStatusCode();
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiGenerateResponse>(cancellationToken);
stopwatch.Stop();
if (geminiResponse?.Candidates is null || geminiResponse.Candidates.Count == 0)
{
throw new InvalidOperationException("No completion returned from Gemini");
}
var candidate = geminiResponse.Candidates[0];
var content = candidate.Content?.Parts?.FirstOrDefault()?.Text ?? string.Empty;
if (_config.LogUsage && geminiResponse.UsageMetadata is not null)
{
_logger.LogInformation(
"Gemini usage - Model: {Model}, Input: {InputTokens}, Output: {OutputTokens}, Total: {TotalTokens}",
model,
geminiResponse.UsageMetadata.PromptTokenCount,
geminiResponse.UsageMetadata.CandidatesTokenCount,
geminiResponse.UsageMetadata.TotalTokenCount);
}
return new LlmCompletionResult
{
Content = content,
ModelId = geminiResponse.ModelVersion ?? model,
ProviderId = ProviderId,
InputTokens = geminiResponse.UsageMetadata?.PromptTokenCount,
OutputTokens = geminiResponse.UsageMetadata?.CandidatesTokenCount,
TotalTimeMs = stopwatch.ElapsedMilliseconds,
FinishReason = candidate.FinishReason,
Deterministic = temperature == 0,
RequestId = request.RequestId
};
}
public async IAsyncEnumerable<LlmStreamChunk> CompleteStreamAsync(
LlmCompletionRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var model = request.Model ?? _config.Model;
var temperature = request.Temperature > 0 ? request.Temperature : _config.Temperature;
var maxTokens = request.MaxTokens > 0 ? request.MaxTokens : _config.MaxTokens;
var geminiRequest = new GeminiGenerateRequest
{
Contents = BuildContents(request),
GenerationConfig = new GeminiGenerationConfig
{
Temperature = temperature,
MaxOutputTokens = maxTokens,
TopP = _config.TopP,
TopK = _config.TopK,
StopSequences = request.StopSequences?.ToList()
}
};
// Add system instruction if present
if (!string.IsNullOrEmpty(request.SystemPrompt))
{
geminiRequest.SystemInstruction = new GeminiContent
{
Parts = new List<GeminiPart>
{
new GeminiPart { Text = request.SystemPrompt }
}
};
}
var endpoint = $"models/{model}:streamGenerateContent?key={_apiKey}&alt=sse";
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = new StringContent(
JsonSerializer.Serialize(geminiRequest),
Encoding.UTF8,
"application/json")
};
var response = await _httpClient.SendAsync(
httpRequest,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync(cancellationToken)) is not null)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrEmpty(line))
{
continue;
}
if (!line.StartsWith("data: "))
{
continue;
}
var data = line.Substring(6);
GeminiStreamResponse? chunk;
try
{
chunk = JsonSerializer.Deserialize<GeminiStreamResponse>(data);
}
catch
{
continue;
}
if (chunk?.Candidates is null || chunk.Candidates.Count == 0)
{
continue;
}
var candidate = chunk.Candidates[0];
var content = candidate.Content?.Parts?.FirstOrDefault()?.Text ?? string.Empty;
var isFinal = !string.IsNullOrEmpty(candidate.FinishReason);
yield return new LlmStreamChunk
{
Content = content,
IsFinal = isFinal,
FinishReason = candidate.FinishReason
};
if (isFinal)
{
yield break;
}
}
// End of stream
yield return new LlmStreamChunk
{
Content = string.Empty,
IsFinal = true,
FinishReason = "STOP"
};
}
private static List<GeminiContent> BuildContents(LlmCompletionRequest request)
{
var contents = new List<GeminiContent>
{
new GeminiContent
{
Role = "user",
Parts = new List<GeminiPart>
{
new GeminiPart { Text = request.UserPrompt }
}
}
};
return contents;
}
public void Dispose()
{
if (!_disposed)
{
_httpClient.Dispose();
_disposed = true;
}
}
}
// Gemini API models
internal sealed class GeminiGenerateRequest
{
[JsonPropertyName("contents")]
public required List<GeminiContent> Contents { get; set; }
[JsonPropertyName("systemInstruction")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public GeminiContent? SystemInstruction { get; set; }
[JsonPropertyName("generationConfig")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public GeminiGenerationConfig? GenerationConfig { get; set; }
}
internal sealed class GeminiContent
{
[JsonPropertyName("role")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Role { get; set; }
[JsonPropertyName("parts")]
public List<GeminiPart>? Parts { get; set; }
}
internal sealed class GeminiPart
{
[JsonPropertyName("text")]
public string? Text { get; set; }
}
internal sealed class GeminiGenerationConfig
{
[JsonPropertyName("temperature")]
public double Temperature { get; set; }
[JsonPropertyName("maxOutputTokens")]
public int MaxOutputTokens { get; set; }
[JsonPropertyName("topP")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public double TopP { get; set; }
[JsonPropertyName("topK")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int TopK { get; set; }
[JsonPropertyName("stopSequences")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? StopSequences { get; set; }
}
internal sealed class GeminiGenerateResponse
{
[JsonPropertyName("candidates")]
public List<GeminiCandidate>? Candidates { get; set; }
[JsonPropertyName("usageMetadata")]
public GeminiUsageMetadata? UsageMetadata { get; set; }
[JsonPropertyName("modelVersion")]
public string? ModelVersion { get; set; }
}
internal sealed class GeminiCandidate
{
[JsonPropertyName("content")]
public GeminiContent? Content { get; set; }
[JsonPropertyName("finishReason")]
public string? FinishReason { get; set; }
[JsonPropertyName("index")]
public int Index { get; set; }
}
internal sealed class GeminiUsageMetadata
{
[JsonPropertyName("promptTokenCount")]
public int PromptTokenCount { get; set; }
[JsonPropertyName("candidatesTokenCount")]
public int CandidatesTokenCount { get; set; }
[JsonPropertyName("totalTokenCount")]
public int TotalTokenCount { get; set; }
}
internal sealed class GeminiStreamResponse
{
[JsonPropertyName("candidates")]
public List<GeminiCandidate>? Candidates { get; set; }
[JsonPropertyName("usageMetadata")]
public GeminiUsageMetadata? UsageMetadata { get; set; }
}

View File

@@ -132,6 +132,7 @@ public static class LlmProviderPluginExtensions
// Register built-in plugins
catalog.RegisterPlugin(new OpenAiLlmProviderPlugin());
catalog.RegisterPlugin(new ClaudeLlmProviderPlugin());
catalog.RegisterPlugin(new GeminiLlmProviderPlugin());
catalog.RegisterPlugin(new LlamaServerLlmProviderPlugin());
catalog.RegisterPlugin(new OllamaLlmProviderPlugin());
@@ -166,6 +167,7 @@ public static class LlmProviderPluginExtensions
// Register built-in plugins
catalog.RegisterPlugin(new OpenAiLlmProviderPlugin());
catalog.RegisterPlugin(new ClaudeLlmProviderPlugin());
catalog.RegisterPlugin(new GeminiLlmProviderPlugin());
catalog.RegisterPlugin(new LlamaServerLlmProviderPlugin());
catalog.RegisterPlugin(new OllamaLlmProviderPlugin());

View File

@@ -231,7 +231,7 @@ internal sealed class AttestorWebApplicationFactory : WebApplicationFactory<Prog
displayName: null,
configureOptions: options => { options.TimeProvider ??= TimeProvider.System; });
#pragma warning disable CS0618
services.TryAddSingleton<ISystemClock, SystemClock>();
services.TryAddSingleton<TimeProvider, SystemClock>();
#pragma warning restore CS0618
});
}
@@ -246,7 +246,7 @@ internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSche
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock)
TimeProvider clock)
: base(options, logger, encoder, clock)
{
}
@@ -272,3 +272,4 @@ internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSche
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -0,0 +1,295 @@
using StellaOps.Attestor.GraphRoot;
using StellaOps.Attestor.GraphRoot.Models;
using Xunit;
namespace StellaOps.Attestor.GraphRoot.Tests;
/// <summary>
/// Tests for GraphType enum.
/// </summary>
public sealed class GraphTypeTests
{
[Theory]
[InlineData(GraphType.Unknown)]
[InlineData(GraphType.CallGraph)]
[InlineData(GraphType.DependencyGraph)]
[InlineData(GraphType.SbomGraph)]
[InlineData(GraphType.EvidenceGraph)]
[InlineData(GraphType.PolicyGraph)]
[InlineData(GraphType.ProofSpine)]
[InlineData(GraphType.ReachabilityGraph)]
[InlineData(GraphType.VexLinkageGraph)]
public void GraphType_AllValues_AreDefined(GraphType graphType)
{
Assert.True(Enum.IsDefined(graphType));
}
[Theory]
[InlineData(GraphType.Unknown, 0)]
[InlineData(GraphType.CallGraph, 1)]
[InlineData(GraphType.DependencyGraph, 2)]
[InlineData(GraphType.SbomGraph, 3)]
[InlineData(GraphType.EvidenceGraph, 4)]
[InlineData(GraphType.PolicyGraph, 5)]
[InlineData(GraphType.ProofSpine, 6)]
[InlineData(GraphType.ReachabilityGraph, 7)]
[InlineData(GraphType.VexLinkageGraph, 8)]
public void GraphType_Values_HaveCorrectNumericValue(GraphType graphType, int expected)
{
Assert.Equal(expected, (int)graphType);
}
}
/// <summary>
/// Tests for GraphRootPredicateTypes constants.
/// </summary>
public sealed class GraphRootPredicateTypesTests
{
[Fact]
public void GraphRootV1_HasCorrectUri()
{
Assert.Equal("https://stella-ops.org/attestation/graph-root/v1", GraphRootPredicateTypes.GraphRootV1);
}
}
/// <summary>
/// Tests for GraphRootAttestation record.
/// </summary>
public sealed class GraphRootAttestationTests
{
[Fact]
public void GraphRootAttestation_Type_DefaultsToInTotoStatement()
{
var subject = new GraphRootSubject
{
Name = "root-hash",
Digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
};
var predicate = CreateTestPredicate();
var attestation = new GraphRootAttestation
{
Subject = [subject],
Predicate = predicate
};
Assert.Equal("https://in-toto.io/Statement/v1", attestation.Type);
}
[Fact]
public void GraphRootAttestation_PredicateType_DefaultsToGraphRootV1()
{
var subject = new GraphRootSubject
{
Name = "artifact",
Digest = new Dictionary<string, string> { ["sha256"] = "xyz789" }
};
var attestation = new GraphRootAttestation
{
Subject = [subject],
Predicate = CreateTestPredicate()
};
Assert.Equal(GraphRootPredicateTypes.GraphRootV1, attestation.PredicateType);
}
[Fact]
public void GraphRootAttestation_RequiredProperties_MustBeSet()
{
var subjects = new List<GraphRootSubject>
{
new GraphRootSubject
{
Name = "sha256:deadbeef",
Digest = new Dictionary<string, string> { ["sha256"] = "deadbeef" }
}
};
var predicate = CreateTestPredicate();
var attestation = new GraphRootAttestation
{
Subject = subjects,
Predicate = predicate
};
Assert.Single(attestation.Subject);
Assert.NotNull(attestation.Predicate);
}
private static GraphRootPredicate CreateTestPredicate()
{
return new GraphRootPredicate
{
GraphType = "call-graph",
RootHash = "sha256:abc123def456",
NodeCount = 10,
EdgeCount = 15,
NodeIds = ["node-1", "node-2"],
EdgeIds = ["edge-1", "edge-2"],
Inputs = new GraphInputDigests
{
PolicyDigest = "sha256:policy-hash",
FeedsDigest = "sha256:feeds-hash",
ToolchainDigest = "sha256:toolchain-hash",
ParamsDigest = "sha256:params-hash"
},
CanonVersion = "1.0.0",
ComputedAt = DateTimeOffset.UtcNow,
ComputedBy = "test-tool",
ComputedByVersion = "1.0.0"
};
}
}
/// <summary>
/// Tests for GraphRootSubject record.
/// </summary>
public sealed class GraphRootSubjectTests
{
[Fact]
public void GraphRootSubject_RequiredProperties_MustBeSet()
{
var subject = new GraphRootSubject
{
Name = "artifact-name",
Digest = new Dictionary<string, string>
{
["sha256"] = "abcdef123456"
}
};
Assert.Equal("artifact-name", subject.Name);
Assert.Single(subject.Digest);
Assert.Equal("abcdef123456", subject.Digest["sha256"]);
}
[Fact]
public void GraphRootSubject_MultipleDigests_Supported()
{
var subject = new GraphRootSubject
{
Name = "multi-digest-artifact",
Digest = new Dictionary<string, string>
{
["sha256"] = "sha256hash",
["sha512"] = "sha512hash"
}
};
Assert.Equal(2, subject.Digest.Count);
}
}
/// <summary>
/// Tests for GraphRootPredicate record.
/// </summary>
public sealed class GraphRootPredicateTests
{
[Fact]
public void GraphRootPredicate_RootAlgorithm_DefaultsToSha256()
{
var predicate = new GraphRootPredicate
{
GraphType = "dependency-graph",
RootHash = "sha256:hash",
NodeCount = 5,
EdgeCount = 8,
NodeIds = [],
EdgeIds = [],
Inputs = new GraphInputDigests
{
PolicyDigest = "sha256:p",
FeedsDigest = "sha256:f",
ToolchainDigest = "sha256:t",
ParamsDigest = "sha256:params"
},
CanonVersion = "1.0.0",
ComputedAt = DateTimeOffset.UtcNow,
ComputedBy = "test-tool",
ComputedByVersion = "1.0.0"
};
Assert.Equal("sha256", predicate.RootAlgorithm);
}
[Fact]
public void GraphRootPredicate_EvidenceIds_DefaultsToEmpty()
{
var predicate = new GraphRootPredicate
{
GraphType = "sbom-graph",
RootHash = "sha256:xyz",
NodeCount = 100,
EdgeCount = 200,
NodeIds = ["a", "b", "c"],
EdgeIds = ["e1", "e2"],
Inputs = new GraphInputDigests
{
PolicyDigest = "sha256:p",
FeedsDigest = "sha256:f",
ToolchainDigest = "sha256:t",
ParamsDigest = "sha256:params"
},
CanonVersion = "1.0.0",
ComputedAt = DateTimeOffset.UtcNow,
ComputedBy = "test-tool",
ComputedByVersion = "1.0.0"
};
Assert.Empty(predicate.EvidenceIds);
}
[Fact]
public void GraphRootPredicate_WithEvidenceIds_ContainsValues()
{
var predicate = new GraphRootPredicate
{
GraphType = "evidence-graph",
RootHash = "sha256:root",
NodeCount = 20,
EdgeCount = 30,
NodeIds = [],
EdgeIds = [],
Inputs = new GraphInputDigests
{
PolicyDigest = "sha256:p",
FeedsDigest = "sha256:f",
ToolchainDigest = "sha256:t",
ParamsDigest = "sha256:params"
},
EvidenceIds = ["ev-001", "ev-002", "ev-003"],
CanonVersion = "1.0.0",
ComputedAt = DateTimeOffset.UtcNow,
ComputedBy = "test-tool",
ComputedByVersion = "1.0.0"
};
Assert.Equal(3, predicate.EvidenceIds.Count);
}
}
/// <summary>
/// Tests for GraphInputDigests record.
/// </summary>
public sealed class GraphInputDigestsTests
{
[Fact]
public void GraphInputDigests_RequiredProperties_MustBeSet()
{
var inputs = new GraphInputDigests
{
PolicyDigest = "sha256:policy-digest",
FeedsDigest = "sha256:feeds-digest",
ToolchainDigest = "sha256:toolchain-digest",
ParamsDigest = "sha256:params-digest"
};
Assert.Equal("sha256:policy-digest", inputs.PolicyDigest);
Assert.Equal("sha256:feeds-digest", inputs.FeedsDigest);
Assert.Equal("sha256:toolchain-digest", inputs.ToolchainDigest);
Assert.Equal("sha256:params-digest", inputs.ParamsDigest);
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.GraphRoot\StellaOps.Attestor.GraphRoot.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1
}

View File

@@ -0,0 +1,776 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
using System;
using System.CommandLine;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Chat;
using StellaOps.Cli.Services.Models.Chat;
namespace StellaOps.Cli.Commands.Advise;
/// <summary>
/// Command group for AdvisoryAI chat operations (ask, settings, doctor).
/// </summary>
internal static class AdviseChatCommandGroup
{
/// <summary>
/// Build the 'advise ask' command for chat queries.
/// </summary>
public static Command BuildAskCommand(
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var queryArgument = new Argument<string>("query")
{
Description = "The question or query to ask the advisory assistant."
};
var imageOption = new Option<string?>("--image", new[] { "-i" })
{
Description = "Container image reference to scope the query (e.g., myregistry/myimage:v1.0)."
};
var digestOption = new Option<string?>("--digest", new[] { "-d" })
{
Description = "Artifact digest to scope the query (e.g., sha256:abc123...)."
};
var envOption = new Option<string?>("--environment", new[] { "-e" })
{
Description = "Environment context for the query (e.g., production, staging)."
};
var conversationOption = new Option<string?>("--conversation-id", new[] { "-c" })
{
Description = "Conversation ID for follow-up queries."
};
var noActionOption = new Option<bool>("--no-action", new[] { "-n" })
{
Description = "Suppress proposed actions in the response (read-only mode)."
};
noActionOption.SetDefaultValue(true);
var evidenceOption = new Option<bool>("--evidence")
{
Description = "Include evidence links and citations in the response."
};
var formatOption = new Option<string>("--format", new[] { "-f" })
{
Description = "Output format: table, json, markdown (default: table)."
};
formatOption.SetDefaultValue("table");
formatOption.FromAmong("table", "json", "markdown");
var outputOption = new Option<string?>("--output", new[] { "-o" })
{
Description = "Write output to file instead of stdout."
};
var tenantOption = new Option<string?>("--tenant")
{
Description = "Tenant context for the query."
};
var userOption = new Option<string?>("--user")
{
Description = "User context for the query."
};
var ask = new Command("ask", "Ask a question to the advisory assistant with evidence-backed responses.")
{
queryArgument,
imageOption,
digestOption,
envOption,
conversationOption,
noActionOption,
evidenceOption,
formatOption,
outputOption,
tenantOption,
userOption,
verboseOption
};
ask.SetAction(async (parseResult, ct) =>
{
var query = parseResult.GetValue(queryArgument) ?? string.Empty;
var image = parseResult.GetValue(imageOption);
var digest = parseResult.GetValue(digestOption);
var env = parseResult.GetValue(envOption);
var conversationId = parseResult.GetValue(conversationOption);
var noAction = parseResult.GetValue(noActionOption);
var evidence = parseResult.GetValue(evidenceOption);
var format = ParseChatOutputFormat(parseResult.GetValue(formatOption));
var outputPath = parseResult.GetValue(outputOption);
var tenant = parseResult.GetValue(tenantOption);
var user = parseResult.GetValue(userOption);
var verbose = parseResult.GetValue(verboseOption);
await HandleAskAsync(
services,
options,
query,
image,
digest,
env,
conversationId,
noAction,
evidence,
format,
outputPath,
tenant,
user,
verbose,
cancellationToken).ConfigureAwait(false);
});
return ask;
}
/// <summary>
/// Build the 'advise doctor' command for chat diagnostics.
/// </summary>
public static Command BuildDoctorCommand(
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var formatOption = new Option<string>("--format", new[] { "-f" })
{
Description = "Output format: table, json (default: table)."
};
formatOption.SetDefaultValue("table");
formatOption.FromAmong("table", "json");
var outputOption = new Option<string?>("--output", new[] { "-o" })
{
Description = "Write output to file instead of stdout."
};
var tenantOption = new Option<string?>("--tenant")
{
Description = "Tenant context for the query."
};
var userOption = new Option<string?>("--user")
{
Description = "User context for the query."
};
var doctor = new Command("chat-doctor", "Show chat quota status, tool access, and last denial reasons.")
{
formatOption,
outputOption,
tenantOption,
userOption,
verboseOption
};
doctor.SetAction(async (parseResult, ct) =>
{
var format = ParseChatOutputFormat(parseResult.GetValue(formatOption));
var outputPath = parseResult.GetValue(outputOption);
var tenant = parseResult.GetValue(tenantOption);
var user = parseResult.GetValue(userOption);
var verbose = parseResult.GetValue(verboseOption);
await HandleDoctorAsync(
services,
options,
format,
outputPath,
tenant,
user,
verbose,
cancellationToken).ConfigureAwait(false);
});
return doctor;
}
/// <summary>
/// Build the 'advise settings' command group for chat settings management.
/// </summary>
public static Command BuildSettingsCommand(
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var settings = new Command("chat-settings", "Manage advisory chat settings (quotas, tool access).");
// advise settings get
var getCommand = BuildSettingsGetCommand(services, options, verboseOption, cancellationToken);
settings.Add(getCommand);
// advise settings update
var updateCommand = BuildSettingsUpdateCommand(services, options, verboseOption, cancellationToken);
settings.Add(updateCommand);
// advise settings clear
var clearCommand = BuildSettingsClearCommand(services, options, verboseOption, cancellationToken);
settings.Add(clearCommand);
return settings;
}
private static Command BuildSettingsGetCommand(
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var scopeOption = new Option<string>("--scope", new[] { "-s" })
{
Description = "Settings scope: effective, user, tenant (default: effective)."
};
scopeOption.SetDefaultValue("effective");
scopeOption.FromAmong("effective", "user", "tenant");
var formatOption = new Option<string>("--format", new[] { "-f" })
{
Description = "Output format: table, json (default: table)."
};
formatOption.SetDefaultValue("table");
formatOption.FromAmong("table", "json");
var outputOption = new Option<string?>("--output", new[] { "-o" })
{
Description = "Write output to file instead of stdout."
};
var tenantOption = new Option<string?>("--tenant")
{
Description = "Tenant context."
};
var userOption = new Option<string?>("--user")
{
Description = "User context."
};
var get = new Command("get", "Get current chat settings.")
{
scopeOption,
formatOption,
outputOption,
tenantOption,
userOption,
verboseOption
};
get.SetAction(async (parseResult, ct) =>
{
var scope = parseResult.GetValue(scopeOption) ?? "effective";
var format = ParseChatOutputFormat(parseResult.GetValue(formatOption));
var outputPath = parseResult.GetValue(outputOption);
var tenant = parseResult.GetValue(tenantOption);
var user = parseResult.GetValue(userOption);
var verbose = parseResult.GetValue(verboseOption);
await HandleSettingsGetAsync(
services,
options,
scope,
format,
outputPath,
tenant,
user,
verbose,
cancellationToken).ConfigureAwait(false);
});
return get;
}
private static Command BuildSettingsUpdateCommand(
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var scopeOption = new Option<string>("--scope", new[] { "-s" })
{
Description = "Settings scope: user, tenant (default: user)."
};
scopeOption.SetDefaultValue("user");
scopeOption.FromAmong("user", "tenant");
var requestsPerMinuteOption = new Option<int?>("--requests-per-minute")
{
Description = "Set requests per minute quota."
};
var requestsPerDayOption = new Option<int?>("--requests-per-day")
{
Description = "Set requests per day quota."
};
var tokensPerDayOption = new Option<int?>("--tokens-per-day")
{
Description = "Set tokens per day quota."
};
var toolCallsPerDayOption = new Option<int?>("--tool-calls-per-day")
{
Description = "Set tool calls per day quota."
};
var allowAllToolsOption = new Option<bool?>("--allow-all-tools")
{
Description = "Allow all tools (true/false)."
};
var allowedToolsOption = new Option<string[]>("--allowed-tools")
{
Description = "Set allowed tools (comma-separated or repeat option).",
Arity = ArgumentArity.ZeroOrMore
};
allowedToolsOption.AllowMultipleArgumentsPerToken = true;
var formatOption = new Option<string>("--format", new[] { "-f" })
{
Description = "Output format: table, json (default: table)."
};
formatOption.SetDefaultValue("table");
formatOption.FromAmong("table", "json");
var tenantOption = new Option<string?>("--tenant")
{
Description = "Tenant context."
};
var userOption = new Option<string?>("--user")
{
Description = "User context."
};
var update = new Command("update", "Update chat settings.")
{
scopeOption,
requestsPerMinuteOption,
requestsPerDayOption,
tokensPerDayOption,
toolCallsPerDayOption,
allowAllToolsOption,
allowedToolsOption,
formatOption,
tenantOption,
userOption,
verboseOption
};
update.SetAction(async (parseResult, ct) =>
{
var scope = parseResult.GetValue(scopeOption) ?? "user";
var requestsPerMinute = parseResult.GetValue(requestsPerMinuteOption);
var requestsPerDay = parseResult.GetValue(requestsPerDayOption);
var tokensPerDay = parseResult.GetValue(tokensPerDayOption);
var toolCallsPerDay = parseResult.GetValue(toolCallsPerDayOption);
var allowAllTools = parseResult.GetValue(allowAllToolsOption);
var allowedTools = parseResult.GetValue(allowedToolsOption);
var format = ParseChatOutputFormat(parseResult.GetValue(formatOption));
var tenant = parseResult.GetValue(tenantOption);
var user = parseResult.GetValue(userOption);
var verbose = parseResult.GetValue(verboseOption);
await HandleSettingsUpdateAsync(
services,
options,
scope,
requestsPerMinute,
requestsPerDay,
tokensPerDay,
toolCallsPerDay,
allowAllTools,
allowedTools,
format,
tenant,
user,
verbose,
cancellationToken).ConfigureAwait(false);
});
return update;
}
private static Command BuildSettingsClearCommand(
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var scopeOption = new Option<string>("--scope", new[] { "-s" })
{
Description = "Settings scope: user, tenant (default: user)."
};
scopeOption.SetDefaultValue("user");
scopeOption.FromAmong("user", "tenant");
var tenantOption = new Option<string?>("--tenant")
{
Description = "Tenant context."
};
var userOption = new Option<string?>("--user")
{
Description = "User context."
};
var clear = new Command("clear", "Clear chat settings overrides.")
{
scopeOption,
tenantOption,
userOption,
verboseOption
};
clear.SetAction(async (parseResult, ct) =>
{
var scope = parseResult.GetValue(scopeOption) ?? "user";
var tenant = parseResult.GetValue(tenantOption);
var user = parseResult.GetValue(userOption);
var verbose = parseResult.GetValue(verboseOption);
await HandleSettingsClearAsync(
services,
options,
scope,
tenant,
user,
verbose,
cancellationToken).ConfigureAwait(false);
});
return clear;
}
private static ChatOutputFormat ParseChatOutputFormat(string? format)
{
return format?.ToLowerInvariant() switch
{
"json" => ChatOutputFormat.Json,
"markdown" or "md" => ChatOutputFormat.Markdown,
_ => ChatOutputFormat.Table
};
}
private static async Task HandleAskAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string query,
string? image,
string? digest,
string? environment,
string? conversationId,
bool noAction,
bool includeEvidence,
ChatOutputFormat format,
string? outputPath,
string? tenant,
string? user,
bool verbose,
CancellationToken cancellationToken)
{
// Check if LLM provider is configured
if (!options.AdvisoryAi.HasConfiguredProvider())
{
Console.Error.WriteLine("Error: AI/LLM provider not configured.");
Console.Error.WriteLine();
Console.Error.WriteLine("AdvisoryAI features require an LLM provider to be configured.");
Console.Error.WriteLine("Run 'stella setup --step llm' to configure an LLM provider.");
Console.Error.WriteLine();
Console.Error.WriteLine("Alternatively, set one of these environment variables:");
Console.Error.WriteLine(" - OPENAI_API_KEY for OpenAI");
Console.Error.WriteLine(" - ANTHROPIC_API_KEY for Claude (Anthropic)");
Console.Error.WriteLine(" - GEMINI_API_KEY for Google Gemini");
Console.Error.WriteLine(" - GOOGLE_API_KEY for Google Gemini");
Console.Error.WriteLine();
Console.Error.WriteLine("Or configure Ollama for local LLM inference.");
return;
}
if (string.IsNullOrWhiteSpace(query))
{
Console.Error.WriteLine("Error: Query cannot be empty.");
return;
}
var client = CreateChatClient(services, options);
var request = new ChatQueryRequest
{
Query = query,
ImageReference = image,
ArtifactDigest = digest,
Environment = environment,
ConversationId = conversationId,
NoAction = noAction,
IncludeEvidence = includeEvidence
};
try
{
if (verbose)
{
Console.Error.WriteLine($"Sending query: {query}");
if (!string.IsNullOrEmpty(image))
{
Console.Error.WriteLine($"Image: {image}");
}
}
var response = await client.QueryAsync(request, tenant, user, cancellationToken).ConfigureAwait(false);
await using var writer = GetOutputWriter(outputPath);
await ChatRenderer.RenderQueryResponseAsync(response, format, writer, cancellationToken).ConfigureAwait(false);
}
catch (ChatGuardrailException ex)
{
Console.Error.WriteLine($"Guardrail blocked: {ex.Message}");
if (ex.ErrorResponse?.Doctor is not null)
{
Console.Error.WriteLine($"Suggested: {ex.ErrorResponse.Doctor.SuggestedCommand}");
}
}
catch (ChatQuotaExceededException ex)
{
Console.Error.WriteLine($"Quota exceeded: {ex.Message}");
Console.Error.WriteLine("Run 'stella advise chat-doctor' to see quota status.");
}
catch (ChatToolDeniedException ex)
{
Console.Error.WriteLine($"Tool access denied: {ex.Message}");
Console.Error.WriteLine("Run 'stella advise chat-settings get' to see allowed tools.");
}
catch (ChatServiceUnavailableException ex)
{
Console.Error.WriteLine($"Chat service unavailable: {ex.Message}");
}
catch (ChatException ex)
{
Console.Error.WriteLine($"Chat error: {ex.Message}");
}
}
private static async Task HandleDoctorAsync(
IServiceProvider services,
StellaOpsCliOptions options,
ChatOutputFormat format,
string? outputPath,
string? tenant,
string? user,
bool verbose,
CancellationToken cancellationToken)
{
// Check if LLM provider is configured and show status
if (!options.AdvisoryAi.HasConfiguredProvider())
{
Console.WriteLine("AdvisoryAI Configuration Status");
Console.WriteLine("================================");
Console.WriteLine();
Console.WriteLine(" Enabled: No");
Console.WriteLine(" Default Provider: (not configured)");
Console.WriteLine();
Console.WriteLine(" OpenAI: Not configured");
Console.WriteLine(" Claude (Anthropic): Not configured");
Console.WriteLine(" Gemini (Google): Not configured");
Console.WriteLine(" Ollama (Local): Not configured");
Console.WriteLine();
Console.Error.WriteLine("AdvisoryAI features are unavailable without an LLM provider.");
Console.Error.WriteLine("Run 'stella setup --step llm' to configure an LLM provider.");
return;
}
var client = CreateChatClient(services, options);
try
{
if (verbose)
{
Console.Error.WriteLine("Fetching chat diagnostics...");
}
var response = await client.GetDoctorAsync(tenant, user, cancellationToken).ConfigureAwait(false);
await using var writer = GetOutputWriter(outputPath);
await ChatRenderer.RenderDoctorResponseAsync(response, format, writer, cancellationToken).ConfigureAwait(false);
}
catch (ChatException ex)
{
Console.Error.WriteLine($"Error fetching diagnostics: {ex.Message}");
}
}
private static async Task HandleSettingsGetAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string scope,
ChatOutputFormat format,
string? outputPath,
string? tenant,
string? user,
bool verbose,
CancellationToken cancellationToken)
{
var client = CreateChatClient(services, options);
try
{
if (verbose)
{
Console.Error.WriteLine($"Fetching chat settings (scope: {scope})...");
}
var response = await client.GetSettingsAsync(scope, tenant, user, cancellationToken).ConfigureAwait(false);
await using var writer = GetOutputWriter(outputPath);
await ChatRenderer.RenderSettingsResponseAsync(response, format, writer, cancellationToken).ConfigureAwait(false);
}
catch (ChatException ex)
{
Console.Error.WriteLine($"Error fetching settings: {ex.Message}");
}
}
private static async Task HandleSettingsUpdateAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string scope,
int? requestsPerMinute,
int? requestsPerDay,
int? tokensPerDay,
int? toolCallsPerDay,
bool? allowAllTools,
string[]? allowedTools,
ChatOutputFormat format,
string? tenant,
string? user,
bool verbose,
CancellationToken cancellationToken)
{
var client = CreateChatClient(services, options);
var quotas = (requestsPerMinute.HasValue || requestsPerDay.HasValue || tokensPerDay.HasValue || toolCallsPerDay.HasValue)
? new ChatQuotaSettingsUpdate
{
RequestsPerMinute = requestsPerMinute,
RequestsPerDay = requestsPerDay,
TokensPerDay = tokensPerDay,
ToolCallsPerDay = toolCallsPerDay
}
: null;
var tools = (allowAllTools.HasValue || (allowedTools?.Length > 0))
? new ChatToolSettingsUpdate
{
AllowAll = allowAllTools,
AllowedTools = allowedTools?.Length > 0 ? [.. allowedTools] : null
}
: null;
if (quotas is null && tools is null)
{
Console.Error.WriteLine("Error: No settings specified to update.");
Console.Error.WriteLine("Use --requests-per-minute, --tokens-per-day, --allow-all-tools, etc.");
return;
}
var request = new ChatSettingsUpdateRequest
{
Quotas = quotas,
Tools = tools
};
try
{
if (verbose)
{
Console.Error.WriteLine($"Updating chat settings (scope: {scope})...");
}
var response = await client.UpdateSettingsAsync(request, scope, tenant, user, cancellationToken).ConfigureAwait(false);
Console.WriteLine("Settings updated successfully.");
Console.WriteLine();
await using var writer = Console.Out;
await ChatRenderer.RenderSettingsResponseAsync(response, format, writer, cancellationToken).ConfigureAwait(false);
}
catch (ChatException ex)
{
Console.Error.WriteLine($"Error updating settings: {ex.Message}");
}
}
private static async Task HandleSettingsClearAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string scope,
string? tenant,
string? user,
bool verbose,
CancellationToken cancellationToken)
{
var client = CreateChatClient(services, options);
try
{
if (verbose)
{
Console.Error.WriteLine($"Clearing chat settings overrides (scope: {scope})...");
}
await client.ClearSettingsAsync(scope, tenant, user, cancellationToken).ConfigureAwait(false);
Console.WriteLine($"Settings cleared for scope: {scope}");
}
catch (ChatException ex)
{
Console.Error.WriteLine($"Error clearing settings: {ex.Message}");
}
}
private static IChatClient CreateChatClient(IServiceProvider services, StellaOpsCliOptions options)
{
// Try to get from DI first
var client = services.GetService<IChatClient>();
if (client is not null)
{
return client;
}
// Create manually with HttpClient
var httpClientFactory = services.GetService<IHttpClientFactory>();
var httpClient = httpClientFactory?.CreateClient("ChatClient") ?? new HttpClient();
return new ChatClient(httpClient, options);
}
private static TextWriter GetOutputWriter(string? outputPath)
{
if (string.IsNullOrEmpty(outputPath))
{
return Console.Out;
}
var directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
return new StreamWriter(outputPath, append: false, encoding: System.Text.Encoding.UTF8);
}
}

View File

@@ -0,0 +1,431 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models.Chat;
namespace StellaOps.Cli.Commands.Advise;
/// <summary>
/// Renders chat responses in various output formats.
/// </summary>
internal static class ChatRenderer
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
/// <summary>
/// Render a chat query response.
/// </summary>
public static async Task RenderQueryResponseAsync(
ChatQueryResponse response,
ChatOutputFormat format,
TextWriter writer,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(response);
ArgumentNullException.ThrowIfNull(writer);
switch (format)
{
case ChatOutputFormat.Json:
await RenderQueryJsonAsync(response, writer, cancellationToken).ConfigureAwait(false);
break;
case ChatOutputFormat.Markdown:
await RenderQueryMarkdownAsync(response, writer, cancellationToken).ConfigureAwait(false);
break;
case ChatOutputFormat.Table:
default:
await RenderQueryTableAsync(response, writer, cancellationToken).ConfigureAwait(false);
break;
}
}
/// <summary>
/// Render a chat doctor response.
/// </summary>
public static async Task RenderDoctorResponseAsync(
ChatDoctorResponse response,
ChatOutputFormat format,
TextWriter writer,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(response);
ArgumentNullException.ThrowIfNull(writer);
switch (format)
{
case ChatOutputFormat.Json:
await RenderDoctorJsonAsync(response, writer, cancellationToken).ConfigureAwait(false);
break;
case ChatOutputFormat.Markdown:
case ChatOutputFormat.Table:
default:
await RenderDoctorTableAsync(response, writer, cancellationToken).ConfigureAwait(false);
break;
}
}
/// <summary>
/// Render a chat settings response.
/// </summary>
public static async Task RenderSettingsResponseAsync(
ChatSettingsResponse response,
ChatOutputFormat format,
TextWriter writer,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(response);
ArgumentNullException.ThrowIfNull(writer);
switch (format)
{
case ChatOutputFormat.Json:
await RenderSettingsJsonAsync(response, writer, cancellationToken).ConfigureAwait(false);
break;
case ChatOutputFormat.Markdown:
case ChatOutputFormat.Table:
default:
await RenderSettingsTableAsync(response, writer, cancellationToken).ConfigureAwait(false);
break;
}
}
private static async Task RenderQueryJsonAsync(ChatQueryResponse response, TextWriter writer, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(response, JsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
private static async Task RenderQueryTableAsync(ChatQueryResponse response, TextWriter writer, CancellationToken cancellationToken)
{
var sb = new StringBuilder();
sb.AppendLine();
sb.AppendLine("=== Advisory Chat Response ===");
sb.AppendLine();
sb.AppendLine($"Response ID: {response.ResponseId}");
sb.AppendLine($"Intent: {response.Intent}");
sb.AppendLine($"Generated: {response.GeneratedAt:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine($"Confidence: {response.Confidence.Overall:P0}");
sb.AppendLine();
sb.AppendLine("--- Summary ---");
sb.AppendLine(response.Summary);
sb.AppendLine();
if (response.Impact is not null)
{
sb.AppendLine("--- Impact ---");
sb.AppendLine($"Severity: {response.Impact.Severity ?? "Unknown"}");
if (response.Impact.AffectedComponents.Count > 0)
{
sb.AppendLine($"Affected: {string.Join(", ", response.Impact.AffectedComponents)}");
}
if (!string.IsNullOrEmpty(response.Impact.Description))
{
sb.AppendLine(response.Impact.Description);
}
sb.AppendLine();
}
if (response.Reachability is not null)
{
sb.AppendLine("--- Reachability ---");
sb.AppendLine($"Reachable: {(response.Reachability.Reachable ? "Yes" : "No")}");
sb.AppendLine($"Confidence: {response.Reachability.Confidence:P0}");
if (response.Reachability.Paths.Count > 0)
{
sb.AppendLine($"Paths: {response.Reachability.Paths.Count}");
}
sb.AppendLine();
}
if (response.Mitigations.Count > 0)
{
sb.AppendLine("--- Mitigations ---");
foreach (var mitigation in response.Mitigations)
{
var recommended = mitigation.Recommended ? " [RECOMMENDED]" : "";
sb.AppendLine($" [{mitigation.Id}] {mitigation.Title}{recommended}");
if (!string.IsNullOrEmpty(mitigation.Description))
{
sb.AppendLine($" {mitigation.Description}");
}
if (!string.IsNullOrEmpty(mitigation.Effort))
{
sb.AppendLine($" Effort: {mitigation.Effort}");
}
}
sb.AppendLine();
}
if (response.EvidenceLinks.Count > 0)
{
sb.AppendLine("--- Evidence ---");
foreach (var evidence in response.EvidenceLinks)
{
var label = evidence.Label ?? evidence.Type;
sb.AppendLine($" [{evidence.Type}] {label}: {evidence.Ref}");
}
sb.AppendLine();
}
if (response.ProposedActions.Count > 0)
{
sb.AppendLine("--- Proposed Actions ---");
foreach (var action in response.ProposedActions)
{
var status = action.Denied ? " [DENIED]" : (action.RequiresConfirmation ? " [REQUIRES CONFIRMATION]" : "");
sb.AppendLine($" [{action.Id}] {action.Tool}: {action.Description}{status}");
if (action.Denied && !string.IsNullOrEmpty(action.DenyReason))
{
sb.AppendLine($" Reason: {action.DenyReason}");
}
}
sb.AppendLine();
}
if (response.FollowUp is not null && response.FollowUp.SuggestedQueries.Count > 0)
{
sb.AppendLine("--- Follow-up Suggestions ---");
foreach (var query in response.FollowUp.SuggestedQueries)
{
sb.AppendLine($" - {query}");
}
sb.AppendLine();
}
if (response.Diagnostics is not null)
{
sb.AppendLine("--- Diagnostics ---");
sb.AppendLine($"Tokens Used: {response.Diagnostics.TokensUsed}");
sb.AppendLine($"Processing Time: {response.Diagnostics.ProcessingTimeMs}ms");
sb.AppendLine($"Sources Queried: {response.Diagnostics.EvidenceSourcesQueried}");
}
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
}
private static async Task RenderQueryMarkdownAsync(ChatQueryResponse response, TextWriter writer, CancellationToken cancellationToken)
{
var sb = new StringBuilder();
sb.AppendLine("# Advisory Chat Response");
sb.AppendLine();
sb.AppendLine($"**Response ID:** `{response.ResponseId}` ");
sb.AppendLine($"**Intent:** {response.Intent} ");
sb.AppendLine($"**Generated:** {response.GeneratedAt:yyyy-MM-dd HH:mm:ss} UTC ");
sb.AppendLine($"**Confidence:** {response.Confidence.Overall:P0}");
sb.AppendLine();
sb.AppendLine("## Summary");
sb.AppendLine();
sb.AppendLine(response.Summary);
sb.AppendLine();
if (response.Impact is not null)
{
sb.AppendLine("## Impact Assessment");
sb.AppendLine();
sb.AppendLine($"- **Severity:** {response.Impact.Severity ?? "Unknown"}");
if (response.Impact.AffectedComponents.Count > 0)
{
sb.AppendLine($"- **Affected Components:** {string.Join(", ", response.Impact.AffectedComponents)}");
}
if (!string.IsNullOrEmpty(response.Impact.Description))
{
sb.AppendLine();
sb.AppendLine(response.Impact.Description);
}
sb.AppendLine();
}
if (response.Mitigations.Count > 0)
{
sb.AppendLine("## Mitigations");
sb.AppendLine();
foreach (var mitigation in response.Mitigations)
{
var recommended = mitigation.Recommended ? " **(Recommended)**" : "";
sb.AppendLine($"### {mitigation.Title}{recommended}");
sb.AppendLine();
if (!string.IsNullOrEmpty(mitigation.Description))
{
sb.AppendLine(mitigation.Description);
sb.AppendLine();
}
if (!string.IsNullOrEmpty(mitigation.Effort))
{
sb.AppendLine($"*Effort: {mitigation.Effort}*");
sb.AppendLine();
}
}
}
if (response.EvidenceLinks.Count > 0)
{
sb.AppendLine("## Evidence");
sb.AppendLine();
sb.AppendLine("| Type | Reference | Label |");
sb.AppendLine("|------|-----------|-------|");
foreach (var evidence in response.EvidenceLinks)
{
sb.AppendLine($"| {evidence.Type} | `{evidence.Ref}` | {evidence.Label ?? "-"} |");
}
sb.AppendLine();
}
if (response.FollowUp is not null && response.FollowUp.SuggestedQueries.Count > 0)
{
sb.AppendLine("## Follow-up Questions");
sb.AppendLine();
foreach (var query in response.FollowUp.SuggestedQueries)
{
sb.AppendLine($"- {query}");
}
sb.AppendLine();
}
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
}
private static async Task RenderDoctorJsonAsync(ChatDoctorResponse response, TextWriter writer, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(response, JsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
private static async Task RenderDoctorTableAsync(ChatDoctorResponse response, TextWriter writer, CancellationToken cancellationToken)
{
var sb = new StringBuilder();
sb.AppendLine();
sb.AppendLine("=== Advisory Chat Doctor ===");
sb.AppendLine();
sb.AppendLine($"Tenant: {response.TenantId}");
sb.AppendLine($"User: {response.UserId}");
sb.AppendLine();
sb.AppendLine("--- Quotas ---");
sb.AppendLine();
sb.AppendLine(" Limit Remaining Resets At");
sb.AppendLine($"Requests/Minute: {response.Quotas.RequestsPerMinuteLimit,5} {response.Quotas.RequestsPerMinuteRemaining,9} {response.Quotas.RequestsPerMinuteResetsAt:HH:mm:ss}");
sb.AppendLine($"Requests/Day: {response.Quotas.RequestsPerDayLimit,5} {response.Quotas.RequestsPerDayRemaining,9} {response.Quotas.RequestsPerDayResetsAt:HH:mm:ss}");
sb.AppendLine($"Tokens/Day: {response.Quotas.TokensPerDayLimit,5} {response.Quotas.TokensPerDayRemaining,9} {response.Quotas.TokensPerDayResetsAt:HH:mm:ss}");
sb.AppendLine();
sb.AppendLine("--- Tool Access ---");
sb.AppendLine();
sb.AppendLine($"Allow All: {(response.Tools.AllowAll ? "Yes" : "No")}");
if (response.Tools.AllowedTools.Count > 0)
{
sb.AppendLine($"Allowed: {string.Join(", ", response.Tools.AllowedTools)}");
}
if (response.Tools.Providers is not null)
{
sb.AppendLine();
sb.AppendLine("Providers:");
sb.AppendLine($" SBOM: {(response.Tools.Providers.Sbom ? "Enabled" : "Disabled")}");
sb.AppendLine($" VEX: {(response.Tools.Providers.Vex ? "Enabled" : "Disabled")}");
sb.AppendLine($" Reachability: {(response.Tools.Providers.Reachability ? "Enabled" : "Disabled")}");
sb.AppendLine($" Policy: {(response.Tools.Providers.Policy ? "Enabled" : "Disabled")}");
sb.AppendLine($" Findings: {(response.Tools.Providers.Findings ? "Enabled" : "Disabled")}");
}
sb.AppendLine();
if (response.LastDenied is not null)
{
sb.AppendLine("--- Last Denial ---");
sb.AppendLine();
sb.AppendLine($"Time: {response.LastDenied.Timestamp:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine($"Reason: {response.LastDenied.Reason}");
if (!string.IsNullOrEmpty(response.LastDenied.Code))
{
sb.AppendLine($"Code: {response.LastDenied.Code}");
}
if (!string.IsNullOrEmpty(response.LastDenied.Query))
{
sb.AppendLine($"Query: {response.LastDenied.Query}");
}
}
else
{
sb.AppendLine("No recent denials.");
}
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
}
private static async Task RenderSettingsJsonAsync(ChatSettingsResponse response, TextWriter writer, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(response, JsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
private static async Task RenderSettingsTableAsync(ChatSettingsResponse response, TextWriter writer, CancellationToken cancellationToken)
{
var sb = new StringBuilder();
sb.AppendLine();
sb.AppendLine("=== Advisory Chat Settings ===");
sb.AppendLine();
sb.AppendLine($"Tenant: {response.TenantId}");
sb.AppendLine($"User: {response.UserId ?? "(not set)"}");
sb.AppendLine($"Scope: {response.Scope}");
sb.AppendLine();
if (response.Effective is not null)
{
sb.AppendLine("--- Effective Settings ---");
sb.AppendLine($"Source: {response.Effective.Source}");
sb.AppendLine();
sb.AppendLine("Quotas:");
sb.AppendLine($" Requests/Minute: {response.Effective.Quotas.RequestsPerMinute?.ToString() ?? "default"}");
sb.AppendLine($" Requests/Day: {response.Effective.Quotas.RequestsPerDay?.ToString() ?? "default"}");
sb.AppendLine($" Tokens/Day: {response.Effective.Quotas.TokensPerDay?.ToString() ?? "default"}");
sb.AppendLine($" Tool Calls/Day: {response.Effective.Quotas.ToolCallsPerDay?.ToString() ?? "default"}");
sb.AppendLine();
sb.AppendLine("Tools:");
sb.AppendLine($" Allow All: {(response.Effective.Tools.AllowAll == true ? "Yes" : "No")}");
if (response.Effective.Tools.AllowedTools?.Count > 0)
{
sb.AppendLine($" Allowed: {string.Join(", ", response.Effective.Tools.AllowedTools)}");
}
}
else
{
if (response.Quotas is not null)
{
sb.AppendLine("--- Quota Overrides ---");
sb.AppendLine($"Requests/Minute: {response.Quotas.RequestsPerMinute?.ToString() ?? "(not set)"}");
sb.AppendLine($"Requests/Day: {response.Quotas.RequestsPerDay?.ToString() ?? "(not set)"}");
sb.AppendLine($"Tokens/Day: {response.Quotas.TokensPerDay?.ToString() ?? "(not set)"}");
sb.AppendLine($"Tool Calls/Day: {response.Quotas.ToolCallsPerDay?.ToString() ?? "(not set)"}");
sb.AppendLine();
}
if (response.Tools is not null)
{
sb.AppendLine("--- Tool Overrides ---");
sb.AppendLine($"Allow All: {(response.Tools.AllowAll == true ? "Yes" : (response.Tools.AllowAll == false ? "No" : "(not set)"))}");
if (response.Tools.AllowedTools?.Count > 0)
{
sb.AppendLine($"Allowed: {string.Join(", ", response.Tools.AllowedTools)}");
}
}
}
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -12,6 +12,7 @@ using StellaOps.Cli.Commands.Scan;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Plugins;
using StellaOps.Cli.Commands.Advise;
using StellaOps.Cli.Services.Models.AdvisoryAi;
namespace StellaOps.Cli.Commands;
@@ -1084,6 +1085,10 @@ internal static class CommandFactory
});
sources.Add(ingest);
// Add sources management commands (list, check, enable, disable, status)
Sources.SourcesCommandGroup.AddSourcesManagementCommands(sources, services, verboseOption, cancellationToken);
return sources;
}
@@ -3319,6 +3324,12 @@ internal static class CommandFactory
advise.Add(explain);
advise.Add(remediate);
advise.Add(batch);
// Sprint: SPRINT_20260113_005_CLI_advise_chat - Chat commands
advise.Add(AdviseChatCommandGroup.BuildAskCommand(services, options, verboseOption, cancellationToken));
advise.Add(AdviseChatCommandGroup.BuildDoctorCommand(services, options, verboseOption, cancellationToken));
advise.Add(AdviseChatCommandGroup.BuildSettingsCommand(services, options, verboseOption, cancellationToken));
return advise;
}

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Cli.Commands.Setup.Config;
using StellaOps.Cli.Commands.Setup.State;
using StellaOps.Cli.Commands.Setup.Steps;
using StellaOps.Cli.Commands.Setup.Steps.Implementations;
namespace StellaOps.Cli.Commands.Setup;
@@ -22,6 +23,19 @@ public static class SetupServiceCollectionExtensions
services.TryAddSingleton<ISetupConfigParser, YamlSetupConfigParser>();
// Register built-in setup steps
// Security steps (required)
services.AddSetupStep<AuthoritySetupStep>();
services.AddSetupStep<UsersSetupStep>();
// Infrastructure steps
services.AddSetupStep<DatabaseSetupStep>();
services.AddSetupStep<CacheSetupStep>();
services.AddSetupStep<VaultSetupStep>();
services.AddSetupStep<SettingsStoreSetupStep>();
services.AddSetupStep<RegistrySetupStep>();
services.AddSetupStep<TelemetrySetupStep>();
// Step catalog
services.TryAddSingleton<SetupStepCatalog>(sp =>
{

View File

@@ -0,0 +1,283 @@
using System;
using System.Collections.Generic;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
/// <summary>
/// Setup step for authority/authentication plugin configuration.
/// </summary>
public sealed class AuthoritySetupStep : SetupStepBase
{
private const string DefaultLdapPort = "389";
private const string DefaultLdapsPort = "636";
public AuthoritySetupStep()
: base(
id: "authority",
name: "Authentication Provider",
description: "Configure authentication provider (Standard password auth or LDAP).",
category: SetupCategory.Security,
order: 10,
isRequired: true,
validationChecks: new[] { "check.authority.plugin.configured", "check.authority.plugin.connectivity" })
{
}
public override async Task<SetupStepResult> ExecuteAsync(
SetupStepContext context,
CancellationToken ct = default)
{
Output(context, "Configuring authentication provider...");
try
{
// Get provider type
var providerType = GetOrPromptChoice(
context,
"authority.provider",
"Select authentication provider",
new[] { "standard", "ldap" },
"standard");
var appliedConfig = new Dictionary<string, string>
{
["authority.provider"] = providerType
};
if (providerType == "standard")
{
return await ConfigureStandardProviderAsync(context, appliedConfig, ct);
}
else if (providerType == "ldap")
{
return await ConfigureLdapProviderAsync(context, appliedConfig, ct);
}
else
{
return SetupStepResult.Failed(
$"Unknown provider type: {providerType}",
canRetry: true);
}
}
catch (Exception ex)
{
OutputError(context, $"Authority setup failed: {ex.Message}");
return SetupStepResult.Failed(
$"Authority setup failed: {ex.Message}",
exception: ex,
canRetry: true);
}
}
private Task<SetupStepResult> ConfigureStandardProviderAsync(
SetupStepContext context,
Dictionary<string, string> appliedConfig,
CancellationToken ct)
{
Output(context, "Configuring Standard (password) authentication...");
// Get password policy settings
var minLength = GetIntOrDefault(context, "authority.password.minLength", 12);
var requireUppercase = GetBoolOrDefault(context, "authority.password.requireUppercase", true);
var requireLowercase = GetBoolOrDefault(context, "authority.password.requireLowercase", true);
var requireDigit = GetBoolOrDefault(context, "authority.password.requireDigit", true);
var requireSpecialChar = GetBoolOrDefault(context, "authority.password.requireSpecialCharacter", true);
appliedConfig["Authority:Plugins:Standard:Enabled"] = "true";
appliedConfig["Authority:PasswordPolicy:MinLength"] = minLength.ToString();
appliedConfig["Authority:PasswordPolicy:RequireUppercase"] = requireUppercase.ToString().ToLowerInvariant();
appliedConfig["Authority:PasswordPolicy:RequireLowercase"] = requireLowercase.ToString().ToLowerInvariant();
appliedConfig["Authority:PasswordPolicy:RequireDigit"] = requireDigit.ToString().ToLowerInvariant();
appliedConfig["Authority:PasswordPolicy:RequireSpecialCharacter"] = requireSpecialChar.ToString().ToLowerInvariant();
if (context.DryRun)
{
Output(context, "[DRY RUN] Would configure Standard authentication with the following password policy:");
Output(context, $" - Minimum length: {minLength}");
Output(context, $" - Require uppercase: {requireUppercase}");
Output(context, $" - Require lowercase: {requireLowercase}");
Output(context, $" - Require digit: {requireDigit}");
Output(context, $" - Require special character: {requireSpecialChar}");
return Task.FromResult(SetupStepResult.Success(
"Standard authentication prepared (dry run)",
appliedConfig: appliedConfig));
}
Output(context, "Standard authentication configured.");
Output(context, $"Password policy: min {minLength} chars, uppercase={requireUppercase}, lowercase={requireLowercase}, digit={requireDigit}, special={requireSpecialChar}");
return Task.FromResult(SetupStepResult.Success(
"Standard authentication configured successfully",
appliedConfig: appliedConfig));
}
private async Task<SetupStepResult> ConfigureLdapProviderAsync(
SetupStepContext context,
Dictionary<string, string> appliedConfig,
CancellationToken ct)
{
Output(context, "Configuring LDAP authentication...");
// Get LDAP server details
var server = GetOrPrompt(context, "authority.ldap.server", "LDAP server URL (e.g., ldap://ldap.example.com)");
var port = GetOrPrompt(context, "authority.ldap.port", "LDAP port", DefaultLdapPort);
var useSsl = GetBoolOrDefault(context, "authority.ldap.ssl", server.StartsWith("ldaps://", StringComparison.OrdinalIgnoreCase));
var bindDn = GetOrPrompt(context, "authority.ldap.bindDn", "Bind DN (e.g., cn=admin,dc=example,dc=com)");
var bindPassword = GetOrPromptSecret(context, "authority.ldap.bindPassword", "Bind password");
var searchBase = GetOrPrompt(context, "authority.ldap.searchBase", "User search base (e.g., ou=users,dc=example,dc=com)");
var userFilter = GetOrPrompt(context, "authority.ldap.userFilter", "User filter", "(uid={0})");
var groupSearchBase = GetOrPrompt(context, "authority.ldap.groupSearchBase", "Group search base (optional, press Enter to skip)", "");
appliedConfig["Authority:Plugins:Ldap:Enabled"] = "true";
appliedConfig["Authority:Plugins:Ldap:Server"] = server;
appliedConfig["Authority:Plugins:Ldap:Port"] = port;
appliedConfig["Authority:Plugins:Ldap:UseSsl"] = useSsl.ToString().ToLowerInvariant();
appliedConfig["Authority:Plugins:Ldap:BindDn"] = bindDn;
appliedConfig["Authority:Plugins:Ldap:BindPassword"] = bindPassword;
appliedConfig["Authority:Plugins:Ldap:SearchBase"] = searchBase;
appliedConfig["Authority:Plugins:Ldap:UserFilter"] = userFilter;
if (!string.IsNullOrWhiteSpace(groupSearchBase))
{
appliedConfig["Authority:Plugins:Ldap:GroupSearchBase"] = groupSearchBase;
}
if (context.DryRun)
{
Output(context, "[DRY RUN] Would configure LDAP authentication:");
Output(context, $" - Server: {server}:{port}");
Output(context, $" - SSL: {useSsl}");
Output(context, $" - Search base: {searchBase}");
return SetupStepResult.Success(
"LDAP authentication prepared (dry run)",
appliedConfig: appliedConfig);
}
// Test LDAP connectivity
Output(context, $"Testing LDAP connection to {server}:{port}...");
var connectionResult = await TestLdapConnectionAsync(server, int.Parse(port), ct);
if (!connectionResult.Success)
{
OutputWarning(context, $"LDAP connection test failed: {connectionResult.Error}");
var proceed = context.PromptForConfirmation("Continue anyway?", false);
if (!proceed)
{
return SetupStepResult.Failed(
$"LDAP connection failed: {connectionResult.Error}",
canRetry: true);
}
}
else
{
Output(context, "LDAP connection successful.");
}
Output(context, "LDAP authentication configured.");
return SetupStepResult.Success(
$"LDAP authentication configured: {server}",
appliedConfig: appliedConfig);
}
public override Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
SetupStepContext context,
CancellationToken ct = default)
{
var hasProvider = context.ConfigValues.ContainsKey("authority.provider");
var isInteractive = !context.NonInteractive;
if (!hasProvider && !isInteractive)
{
return Task.FromResult(SetupStepPrerequisiteResult.Failed(
"Authority provider selection required in non-interactive mode",
missing: new[] { "authority.provider" },
suggestions: new[]
{
"Set authority.provider to 'standard' or 'ldap'",
"For LDAP, also provide authority.ldap.server, authority.ldap.bindDn, etc."
}));
}
return Task.FromResult(SetupStepPrerequisiteResult.Success());
}
public override async Task<SetupStepValidationResult> ValidateAsync(
SetupStepContext context,
CancellationToken ct = default)
{
var provider = GetOrDefault(context, "authority.provider", "standard");
if (provider == "ldap")
{
var server = GetOrDefault(context, "Authority:Plugins:Ldap:Server", null);
var port = GetOrDefault(context, "Authority:Plugins:Ldap:Port", DefaultLdapPort);
if (string.IsNullOrEmpty(server))
{
return SetupStepValidationResult.Failed(
"LDAP server not configured",
errors: new[] { "Authority:Plugins:Ldap:Server is not set" });
}
var connectionResult = await TestLdapConnectionAsync(server, int.Parse(port!), ct);
if (!connectionResult.Success)
{
return SetupStepValidationResult.Warning(
$"LDAP connectivity issue: {connectionResult.Error}",
warnings: new[] { connectionResult.Error! });
}
}
return SetupStepValidationResult.Success("Authority provider configured");
}
private static async Task<(bool Success, string? Error)> TestLdapConnectionAsync(
string server,
int port,
CancellationToken ct)
{
try
{
var uri = new Uri(server);
var host = uri.Host;
var actualPort = uri.Port > 0 ? uri.Port : port;
using var client = new TcpClient();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(10));
await client.ConnectAsync(host, actualPort, cts.Token);
return (true, null);
}
catch (OperationCanceledException)
{
return (false, "Connection timed out");
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
private string GetOrPromptChoice(
SetupStepContext context,
string key,
string prompt,
string[] options,
string defaultValue)
{
if (context.ConfigValues.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
{
return value;
}
return context.PromptForChoice(prompt, options, defaultValue);
}
private new static string? GetOrDefault(SetupStepContext context, string key, string? defaultValue)
{
return context.ConfigValues.TryGetValue(key, out var value) ? value : defaultValue;
}
}

View File

@@ -0,0 +1,246 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StackExchange.Redis;
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
/// <summary>
/// Setup step for Valkey/Redis cache configuration.
/// </summary>
public sealed class CacheSetupStep : SetupStepBase
{
private const string DefaultHost = "localhost";
private const int DefaultPort = 6379;
private const int DefaultDatabase = 0;
public CacheSetupStep()
: base(
id: "cache",
name: "Valkey/Redis Cache",
description: "Configure the Valkey or Redis cache connection for StellaOps.",
category: SetupCategory.Infrastructure,
order: 20,
isRequired: true,
dependencies: new[] { "database" },
validationChecks: new[] { "check.cache.connectivity", "check.cache.memory" })
{
}
public override async Task<SetupStepResult> ExecuteAsync(
SetupStepContext context,
CancellationToken ct = default)
{
Output(context, "Configuring Valkey/Redis cache connection...");
try
{
var host = GetOrPrompt(context, "cache.host", "Cache host", DefaultHost);
var port = GetIntOrDefault(context, "cache.port", DefaultPort);
var password = GetOrPromptSecret(context, "cache.password", "Cache password (leave empty if none)");
var ssl = GetBoolOrDefault(context, "cache.ssl", false);
var database = GetIntOrDefault(context, "cache.database", DefaultDatabase);
var connectionString = BuildConnectionString(host, port, password, ssl, database);
if (context.DryRun)
{
Output(context, $"[DRY RUN] Would configure cache connection to {host}:{port}");
return SetupStepResult.Success(
"Cache configuration prepared (dry run)",
appliedConfig: new Dictionary<string, string>
{
["cache.host"] = host,
["cache.port"] = port.ToString(),
["cache.database"] = database.ToString(),
["cache.ssl"] = ssl.ToString().ToLowerInvariant()
});
}
// Test connection
Output(context, $"Testing connection to {host}:{port}...");
var info = await TestConnectionAsync(connectionString, ct);
Output(context, "Cache connection successful.");
OutputVerbose(context, $"Redis version: {info.Version}");
OutputVerbose(context, $"Connected clients: {info.ConnectedClients}");
OutputVerbose(context, $"Used memory: {FormatBytes(info.UsedMemory)}");
var appliedConfig = new Dictionary<string, string>
{
["cache.host"] = host,
["cache.port"] = port.ToString(),
["cache.database"] = database.ToString(),
["cache.ssl"] = ssl.ToString().ToLowerInvariant(),
["cache.connectionString"] = connectionString
};
var outputValues = new Dictionary<string, string>
{
["cache.version"] = info.Version,
["cache.usedMemory"] = info.UsedMemory.ToString()
};
return SetupStepResult.Success(
$"Cache configured: {host}:{port} (database {database})",
outputValues: outputValues,
appliedConfig: appliedConfig);
}
catch (RedisConnectionException ex)
{
OutputError(context, $"Cache connection failed: {ex.Message}");
return SetupStepResult.Failed(
$"Failed to connect to cache: {ex.Message}",
exception: ex,
canRetry: true);
}
catch (Exception ex)
{
OutputError(context, $"Cache setup failed: {ex.Message}");
return SetupStepResult.Failed(
$"Cache setup failed: {ex.Message}",
exception: ex,
canRetry: true);
}
}
public override async Task<SetupStepValidationResult> ValidateAsync(
SetupStepContext context,
CancellationToken ct = default)
{
var connectionString = GetConnectionStringFromContext(context);
if (string.IsNullOrEmpty(connectionString))
{
return SetupStepValidationResult.Failed(
"Cache not configured",
errors: new[] { "No cache connection string found in configuration." });
}
try
{
await TestConnectionAsync(connectionString, ct);
return SetupStepValidationResult.Success("Cache connection validated");
}
catch (Exception ex)
{
return SetupStepValidationResult.Failed(
"Cache connection validation failed",
errors: new[] { ex.Message });
}
}
public override Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
SetupStepContext context,
CancellationToken ct = default)
{
var hasHost = context.ConfigValues.ContainsKey("cache.host");
var isInteractive = !context.NonInteractive;
if (!hasHost && !isInteractive)
{
return Task.FromResult(SetupStepPrerequisiteResult.Failed(
"Cache configuration required in non-interactive mode",
missing: new[] { "cache.host" },
suggestions: new[]
{
"Provide cache.host in config file",
"Optionally provide cache.port, cache.password, cache.ssl"
}));
}
return Task.FromResult(SetupStepPrerequisiteResult.Success());
}
private static string BuildConnectionString(
string host,
int port,
string? password,
bool ssl,
int database)
{
var options = new ConfigurationOptions
{
EndPoints = { { host, port } },
DefaultDatabase = database,
Ssl = ssl,
AbortOnConnectFail = false,
ConnectTimeout = 5000,
SyncTimeout = 5000
};
if (!string.IsNullOrEmpty(password))
{
options.Password = password;
}
return options.ToString();
}
private static async Task<CacheInfo> TestConnectionAsync(string connectionString, CancellationToken ct)
{
var options = ConfigurationOptions.Parse(connectionString);
options.AbortOnConnectFail = true;
options.ConnectTimeout = 10000;
using var muxer = await ConnectionMultiplexer.ConnectAsync(options);
var server = muxer.GetServer(muxer.GetEndPoints()[0]);
// Get server info
var info = await server.InfoAsync();
var serverInfo = info.FirstOrDefault(g => g.Key == "Server");
var clientsInfo = info.FirstOrDefault(g => g.Key == "Clients");
var memoryInfo = info.FirstOrDefault(g => g.Key == "Memory");
var version = serverInfo?.FirstOrDefault(kv => kv.Key == "redis_version").Value ?? "Unknown";
var connectedClients = int.TryParse(
clientsInfo?.FirstOrDefault(kv => kv.Key == "connected_clients").Value,
out var cc) ? cc : 0;
var usedMemory = long.TryParse(
memoryInfo?.FirstOrDefault(kv => kv.Key == "used_memory").Value,
out var um) ? um : 0;
// Test a simple operation
var db = muxer.GetDatabase();
var testKey = $"__stellaops_setup_test_{Guid.NewGuid():N}";
await db.StringSetAsync(testKey, "test", TimeSpan.FromSeconds(5));
await db.KeyDeleteAsync(testKey);
return new CacheInfo(version, connectedClients, usedMemory);
}
private string? GetConnectionStringFromContext(SetupStepContext context)
{
if (context.ConfigValues.TryGetValue("cache.connectionString", out var connStr))
{
return connStr;
}
if (context.ConfigValues.TryGetValue("cache.host", out var host))
{
var port = GetIntOrDefault(context, "cache.port", DefaultPort);
var password = context.ConfigValues.TryGetValue("cache.password", out var p) ? p : null;
var ssl = GetBoolOrDefault(context, "cache.ssl", false);
var database = GetIntOrDefault(context, "cache.database", DefaultDatabase);
return BuildConnectionString(host, port, password, ssl, database);
}
return null;
}
private static string FormatBytes(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len /= 1024;
}
return $"{len:0.##} {sizes[order]}";
}
private sealed record CacheInfo(string Version, int ConnectedClients, long UsedMemory);
}

View File

@@ -0,0 +1,246 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Npgsql;
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
/// <summary>
/// Setup step for PostgreSQL database configuration.
/// </summary>
public sealed class DatabaseSetupStep : SetupStepBase
{
private const string DefaultHost = "localhost";
private const int DefaultPort = 5432;
private const string DefaultDatabase = "stellaops";
private const string DefaultUser = "stellaops";
public DatabaseSetupStep()
: base(
id: "database",
name: "PostgreSQL Database",
description: "Configure the PostgreSQL database connection for StellaOps.",
category: SetupCategory.Infrastructure,
order: 10,
isRequired: true,
validationChecks: new[] { "check.database.connectivity", "check.database.schema" })
{
}
public override async Task<SetupStepResult> ExecuteAsync(
SetupStepContext context,
CancellationToken ct = default)
{
Output(context, "Configuring PostgreSQL database connection...");
try
{
// Get connection details
var connectionString = GetOrDefault(context, "database.connectionString", null);
string host, database, user, password;
int port;
bool ssl;
if (!string.IsNullOrEmpty(connectionString))
{
OutputVerbose(context, "Using provided connection string");
// Parse connection string for validation
var builder = new NpgsqlConnectionStringBuilder(connectionString);
host = builder.Host ?? DefaultHost;
port = builder.Port;
database = builder.Database ?? DefaultDatabase;
user = builder.Username ?? DefaultUser;
password = builder.Password ?? string.Empty;
ssl = builder.SslMode != SslMode.Disable;
}
else
{
host = GetOrPrompt(context, "database.host", "Database host", DefaultHost);
port = GetIntOrDefault(context, "database.port", DefaultPort);
database = GetOrPrompt(context, "database.database", "Database name", DefaultDatabase);
user = GetOrPrompt(context, "database.user", "Database user", DefaultUser);
password = GetOrPromptSecret(context, "database.password", "Database password");
ssl = GetBoolOrDefault(context, "database.ssl", false);
connectionString = BuildConnectionString(host, port, database, user, password, ssl);
}
if (context.DryRun)
{
Output(context, $"[DRY RUN] Would configure database connection to {host}:{port}/{database}");
return SetupStepResult.Success(
"Database configuration prepared (dry run)",
appliedConfig: new Dictionary<string, string>
{
["database.host"] = host,
["database.port"] = port.ToString(),
["database.database"] = database,
["database.user"] = user,
["database.ssl"] = ssl.ToString().ToLowerInvariant()
});
}
// Test connection
Output(context, $"Testing connection to {host}:{port}/{database}...");
await TestConnectionAsync(connectionString, ct);
Output(context, "Database connection successful.");
// Check and report database version
var version = await GetDatabaseVersionAsync(connectionString, ct);
OutputVerbose(context, $"PostgreSQL version: {version}");
// Store connection string securely (would be written to config in real impl)
var appliedConfig = new Dictionary<string, string>
{
["database.host"] = host,
["database.port"] = port.ToString(),
["database.database"] = database,
["database.user"] = user,
["database.ssl"] = ssl.ToString().ToLowerInvariant(),
["database.connectionString"] = connectionString
};
var outputValues = new Dictionary<string, string>
{
["database.version"] = version
};
return SetupStepResult.Success(
$"Database configured: {host}:{port}/{database}",
outputValues: outputValues,
appliedConfig: appliedConfig);
}
catch (NpgsqlException ex)
{
OutputError(context, $"Database connection failed: {ex.Message}");
return SetupStepResult.Failed(
$"Failed to connect to database: {ex.Message}",
exception: ex,
canRetry: true);
}
catch (Exception ex)
{
OutputError(context, $"Database setup failed: {ex.Message}");
return SetupStepResult.Failed(
$"Database setup failed: {ex.Message}",
exception: ex,
canRetry: true);
}
}
public override async Task<SetupStepValidationResult> ValidateAsync(
SetupStepContext context,
CancellationToken ct = default)
{
var connectionString = GetConnectionStringFromContext(context);
if (string.IsNullOrEmpty(connectionString))
{
return SetupStepValidationResult.Failed(
"Database not configured",
errors: new[] { "No database connection string found in configuration." });
}
try
{
await TestConnectionAsync(connectionString, ct);
return SetupStepValidationResult.Success("Database connection validated");
}
catch (Exception ex)
{
return SetupStepValidationResult.Failed(
"Database connection validation failed",
errors: new[] { ex.Message });
}
}
public override Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
SetupStepContext context,
CancellationToken ct = default)
{
// Check if we have enough info to proceed
var hasConnectionString = context.ConfigValues.ContainsKey("database.connectionString");
var hasHost = context.ConfigValues.ContainsKey("database.host");
var isInteractive = !context.NonInteractive;
if (!hasConnectionString && !hasHost && !isInteractive)
{
return Task.FromResult(SetupStepPrerequisiteResult.Failed(
"Database configuration required in non-interactive mode",
missing: new[] { "database.host or database.connectionString" },
suggestions: new[]
{
"Provide database.connectionString in config file",
"Or provide database.host, database.port, database.database, database.user"
}));
}
return Task.FromResult(SetupStepPrerequisiteResult.Success());
}
private static string BuildConnectionString(
string host,
int port,
string database,
string user,
string password,
bool ssl)
{
var builder = new NpgsqlConnectionStringBuilder
{
Host = host,
Port = port,
Database = database,
Username = user,
Password = password,
SslMode = ssl ? SslMode.Require : SslMode.Prefer,
Timeout = 30,
CommandTimeout = 30
};
return builder.ConnectionString;
}
private static async Task TestConnectionAsync(string connectionString, CancellationToken ct)
{
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(ct);
await using var cmd = new NpgsqlCommand("SELECT 1", connection);
await cmd.ExecuteScalarAsync(ct);
}
private static async Task<string> GetDatabaseVersionAsync(string connectionString, CancellationToken ct)
{
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(ct);
await using var cmd = new NpgsqlCommand("SELECT version()", connection);
var result = await cmd.ExecuteScalarAsync(ct);
return result?.ToString() ?? "Unknown";
}
private string? GetConnectionStringFromContext(SetupStepContext context)
{
if (context.ConfigValues.TryGetValue("database.connectionString", out var connStr))
{
return connStr;
}
if (context.ConfigValues.TryGetValue("database.host", out var host))
{
var port = GetIntOrDefault(context, "database.port", DefaultPort);
var database = GetOrDefault(context, "database.database", DefaultDatabase);
var user = GetOrDefault(context, "database.user", DefaultUser);
var password = GetOrDefault(context, "database.password", string.Empty);
var ssl = GetBoolOrDefault(context, "database.ssl", false);
return BuildConnectionString(host, port, database ?? DefaultDatabase, user ?? DefaultUser, password ?? string.Empty, ssl);
}
return null;
}
private new static string? GetOrDefault(SetupStepContext context, string key, string? defaultValue)
{
return context.ConfigValues.TryGetValue(key, out var value) ? value : defaultValue;
}
}

View File

@@ -0,0 +1,441 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
/// <summary>
/// Setup step for AI/LLM provider configuration.
/// </summary>
public sealed class LlmSetupStep : SetupStepBase
{
private static readonly string[] ProviderTypes = { "openai", "claude", "gemini", "ollama", "none" };
public LlmSetupStep()
: base(
id: "llm",
name: "AI/LLM Provider",
description: "Configure AI/LLM provider for AdvisoryAI features (OpenAI, Claude, Gemini, Ollama).",
category: SetupCategory.Integration,
order: 80,
isRequired: false,
validationChecks: new[] { "check.ai.llm.config", "check.ai.provider.openai", "check.ai.provider.claude", "check.ai.provider.gemini" })
{
}
public override async Task<SetupStepResult> ExecuteAsync(
SetupStepContext context,
CancellationToken ct = default)
{
Output(context, "Configuring AI/LLM provider...");
try
{
var appliedConfig = new Dictionary<string, string>();
// Get provider type selection
var providerType = GetOrPromptChoice(
context,
"llm.provider",
"Select LLM provider",
ProviderTypes,
"openai");
if (providerType == "none")
{
Output(context, "Skipping LLM configuration. AdvisoryAI features will be unavailable.");
appliedConfig["AdvisoryAI:Enabled"] = "false";
return SetupStepResult.Success(
"LLM provider not configured - AdvisoryAI disabled",
appliedConfig: appliedConfig);
}
appliedConfig["AdvisoryAI:Enabled"] = "true";
appliedConfig["AdvisoryAI:DefaultProvider"] = providerType;
// Configure the selected provider
var providerResult = providerType switch
{
"openai" => await ConfigureOpenAiAsync(context, appliedConfig, ct),
"claude" => await ConfigureClaudeAsync(context, appliedConfig, ct),
"gemini" => await ConfigureGeminiAsync(context, appliedConfig, ct),
"ollama" => await ConfigureOllamaAsync(context, appliedConfig, ct),
_ => SetupStepResult.Failed($"Unknown provider type: {providerType}", canRetry: true)
};
if (!providerResult.IsSuccess)
{
return providerResult;
}
Output(context, $"LLM provider '{providerType}' configured successfully.");
return SetupStepResult.Success(
$"LLM provider configured: {providerType}",
appliedConfig: appliedConfig);
}
catch (Exception ex)
{
OutputError(context, $"LLM setup failed: {ex.Message}");
return SetupStepResult.Failed(
$"LLM setup failed: {ex.Message}",
exception: ex,
canRetry: true);
}
}
private async Task<SetupStepResult> ConfigureOpenAiAsync(
SetupStepContext context,
Dictionary<string, string> appliedConfig,
CancellationToken ct)
{
Output(context, "Configuring OpenAI provider...");
var apiKey = GetOrPromptSecret(context, "llm.openai.apiKey", "OpenAI API key (or OPENAI_API_KEY env var)");
var model = GetOrDefault(context, "llm.openai.model", "gpt-4o");
if (string.IsNullOrEmpty(apiKey))
{
apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? "";
if (string.IsNullOrEmpty(apiKey))
{
return SetupStepResult.Failed("OpenAI API key is required", canRetry: true);
}
Output(context, "Using API key from OPENAI_API_KEY environment variable.");
}
appliedConfig["AdvisoryAI:LlmProviders:OpenAI:ApiKey"] = apiKey;
appliedConfig["AdvisoryAI:LlmProviders:OpenAI:Model"] = model;
if (context.DryRun)
{
Output(context, "[DRY RUN] Would configure OpenAI provider:");
Output(context, $" - Model: {model}");
Output(context, $" - API Key: {MaskApiKey(apiKey)}");
return SetupStepResult.Success("OpenAI provider prepared (dry run)", appliedConfig: appliedConfig);
}
// Test API connectivity
Output(context, "Testing OpenAI API connectivity...");
var testResult = await TestOpenAiAsync(apiKey, ct);
if (!testResult.Success)
{
OutputWarning(context, $"OpenAI API test failed: {testResult.Error}");
var proceed = context.PromptForConfirmation("Continue anyway?", false);
if (!proceed)
{
return SetupStepResult.Failed($"OpenAI API test failed: {testResult.Error}", canRetry: true);
}
}
else
{
Output(context, "OpenAI API connection successful.");
}
return SetupStepResult.Success("OpenAI provider configured", appliedConfig: appliedConfig);
}
private async Task<SetupStepResult> ConfigureClaudeAsync(
SetupStepContext context,
Dictionary<string, string> appliedConfig,
CancellationToken ct)
{
Output(context, "Configuring Claude (Anthropic) provider...");
var apiKey = GetOrPromptSecret(context, "llm.claude.apiKey", "Anthropic API key (or ANTHROPIC_API_KEY env var)");
var model = GetOrDefault(context, "llm.claude.model", "claude-sonnet-4-20250514");
if (string.IsNullOrEmpty(apiKey))
{
apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? "";
if (string.IsNullOrEmpty(apiKey))
{
return SetupStepResult.Failed("Anthropic API key is required", canRetry: true);
}
Output(context, "Using API key from ANTHROPIC_API_KEY environment variable.");
}
appliedConfig["AdvisoryAI:LlmProviders:Claude:ApiKey"] = apiKey;
appliedConfig["AdvisoryAI:LlmProviders:Claude:Model"] = model;
if (context.DryRun)
{
Output(context, "[DRY RUN] Would configure Claude provider:");
Output(context, $" - Model: {model}");
Output(context, $" - API Key: {MaskApiKey(apiKey)}");
return SetupStepResult.Success("Claude provider prepared (dry run)", appliedConfig: appliedConfig);
}
// Test API connectivity
Output(context, "Testing Claude API connectivity...");
var testResult = await TestClaudeAsync(apiKey, ct);
if (!testResult.Success)
{
OutputWarning(context, $"Claude API test failed: {testResult.Error}");
var proceed = context.PromptForConfirmation("Continue anyway?", false);
if (!proceed)
{
return SetupStepResult.Failed($"Claude API test failed: {testResult.Error}", canRetry: true);
}
}
else
{
Output(context, "Claude API connection successful.");
}
return SetupStepResult.Success("Claude provider configured", appliedConfig: appliedConfig);
}
private async Task<SetupStepResult> ConfigureGeminiAsync(
SetupStepContext context,
Dictionary<string, string> appliedConfig,
CancellationToken ct)
{
Output(context, "Configuring Google Gemini provider...");
var apiKey = GetOrPromptSecret(context, "llm.gemini.apiKey", "Gemini API key (or GEMINI_API_KEY env var)");
var model = GetOrDefault(context, "llm.gemini.model", "gemini-1.5-flash");
if (string.IsNullOrEmpty(apiKey))
{
apiKey = Environment.GetEnvironmentVariable("GEMINI_API_KEY")
?? Environment.GetEnvironmentVariable("GOOGLE_API_KEY")
?? "";
if (string.IsNullOrEmpty(apiKey))
{
return SetupStepResult.Failed("Gemini API key is required", canRetry: true);
}
Output(context, "Using API key from environment variable.");
}
appliedConfig["AdvisoryAI:LlmProviders:Gemini:ApiKey"] = apiKey;
appliedConfig["AdvisoryAI:LlmProviders:Gemini:Model"] = model;
if (context.DryRun)
{
Output(context, "[DRY RUN] Would configure Gemini provider:");
Output(context, $" - Model: {model}");
Output(context, $" - API Key: {MaskApiKey(apiKey)}");
return SetupStepResult.Success("Gemini provider prepared (dry run)", appliedConfig: appliedConfig);
}
// Test API connectivity
Output(context, "Testing Gemini API connectivity...");
var testResult = await TestGeminiAsync(apiKey, ct);
if (!testResult.Success)
{
OutputWarning(context, $"Gemini API test failed: {testResult.Error}");
var proceed = context.PromptForConfirmation("Continue anyway?", false);
if (!proceed)
{
return SetupStepResult.Failed($"Gemini API test failed: {testResult.Error}", canRetry: true);
}
}
else
{
Output(context, "Gemini API connection successful.");
}
return SetupStepResult.Success("Gemini provider configured", appliedConfig: appliedConfig);
}
private Task<SetupStepResult> ConfigureOllamaAsync(
SetupStepContext context,
Dictionary<string, string> appliedConfig,
CancellationToken ct)
{
Output(context, "Configuring Ollama provider (local LLM)...");
var endpoint = GetOrDefault(context, "llm.ollama.endpoint", "http://localhost:11434");
var model = GetOrDefault(context, "llm.ollama.model", "llama3:8b");
appliedConfig["AdvisoryAI:LlmProviders:Ollama:Enabled"] = "true";
appliedConfig["AdvisoryAI:LlmProviders:Ollama:Endpoint"] = endpoint;
appliedConfig["AdvisoryAI:LlmProviders:Ollama:Model"] = model;
if (context.DryRun)
{
Output(context, "[DRY RUN] Would configure Ollama provider:");
Output(context, $" - Endpoint: {endpoint}");
Output(context, $" - Model: {model}");
return Task.FromResult(SetupStepResult.Success("Ollama provider prepared (dry run)", appliedConfig: appliedConfig));
}
Output(context, "Ollama provider configured.");
Output(context, "Note: Ensure Ollama is running and the model is pulled before using AdvisoryAI.");
return Task.FromResult(SetupStepResult.Success("Ollama provider configured", appliedConfig: appliedConfig));
}
public override Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
SetupStepContext context,
CancellationToken ct = default)
{
// LLM setup has no prerequisites
return Task.FromResult(SetupStepPrerequisiteResult.Success());
}
public override async Task<SetupStepValidationResult> ValidateAsync(
SetupStepContext context,
CancellationToken ct = default)
{
var warnings = new List<string>();
// Check if AdvisoryAI is enabled
if (context.ConfigValues.TryGetValue("AdvisoryAI:Enabled", out var enabled) &&
enabled.Equals("false", StringComparison.OrdinalIgnoreCase))
{
return SetupStepValidationResult.Success("AdvisoryAI is disabled - no LLM validation needed");
}
// Get default provider
var defaultProvider = context.ConfigValues.GetValueOrDefault("AdvisoryAI:DefaultProvider", "");
// Validate OpenAI if configured
if (defaultProvider.Equals("openai", StringComparison.OrdinalIgnoreCase))
{
var apiKey = context.ConfigValues.GetValueOrDefault("AdvisoryAI:LlmProviders:OpenAI:ApiKey")
?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
if (string.IsNullOrEmpty(apiKey))
{
return SetupStepValidationResult.Failed(
"OpenAI is configured as default but API key is not set",
errors: new[] { "Set AdvisoryAI:LlmProviders:OpenAI:ApiKey or OPENAI_API_KEY" });
}
}
// Validate Claude if configured
if (defaultProvider.Equals("claude", StringComparison.OrdinalIgnoreCase))
{
var apiKey = context.ConfigValues.GetValueOrDefault("AdvisoryAI:LlmProviders:Claude:ApiKey")
?? Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY");
if (string.IsNullOrEmpty(apiKey))
{
return SetupStepValidationResult.Failed(
"Claude is configured as default but API key is not set",
errors: new[] { "Set AdvisoryAI:LlmProviders:Claude:ApiKey or ANTHROPIC_API_KEY" });
}
}
// Validate Gemini if configured
if (defaultProvider.Equals("gemini", StringComparison.OrdinalIgnoreCase))
{
var apiKey = context.ConfigValues.GetValueOrDefault("AdvisoryAI:LlmProviders:Gemini:ApiKey")
?? Environment.GetEnvironmentVariable("GEMINI_API_KEY")
?? Environment.GetEnvironmentVariable("GOOGLE_API_KEY");
if (string.IsNullOrEmpty(apiKey))
{
return SetupStepValidationResult.Failed(
"Gemini is configured as default but API key is not set",
errors: new[] { "Set AdvisoryAI:LlmProviders:Gemini:ApiKey or GEMINI_API_KEY/GOOGLE_API_KEY" });
}
}
if (warnings.Count > 0)
{
return SetupStepValidationResult.Warning(
"LLM configuration has warnings",
warnings: warnings);
}
return SetupStepValidationResult.Success("LLM provider configured");
}
private static async Task<(bool Success, string? Error)> TestOpenAiAsync(string apiKey, CancellationToken ct)
{
try
{
using var client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
var response = await client.GetAsync("https://api.openai.com/v1/models", ct);
if (response.IsSuccessStatusCode)
{
return (true, null);
}
return (false, $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}");
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
private static async Task<(bool Success, string? Error)> TestClaudeAsync(string apiKey, CancellationToken ct)
{
try
{
using var client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.Add("x-api-key", apiKey);
client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
// Use a minimal request to test auth
var response = await client.GetAsync("https://api.anthropic.com/v1/messages", ct);
// 405 Method Not Allowed is expected for GET request, but means auth worked
if (response.IsSuccessStatusCode || (int)response.StatusCode == 405)
{
return (true, null);
}
return (false, $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}");
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
private static async Task<(bool Success, string? Error)> TestGeminiAsync(string apiKey, CancellationToken ct)
{
try
{
using var client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(10);
var response = await client.GetAsync(
$"https://generativelanguage.googleapis.com/v1beta/models?key={apiKey}",
ct);
if (response.IsSuccessStatusCode)
{
return (true, null);
}
return (false, $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}");
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
private string GetOrPromptChoice(
SetupStepContext context,
string key,
string prompt,
string[] options,
string defaultValue)
{
if (context.ConfigValues.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
{
return value;
}
return context.PromptForChoice(prompt, options, defaultValue);
}
private static string MaskApiKey(string apiKey)
{
if (string.IsNullOrEmpty(apiKey) || apiKey.Length <= 8)
{
return "****";
}
return apiKey[..4] + "..." + apiKey[^4..];
}
}

View File

@@ -0,0 +1,405 @@
using System;
using System.Collections.Generic;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
/// <summary>
/// Setup step for notification channel configuration.
/// </summary>
public sealed class NotifySetupStep : SetupStepBase
{
private static readonly string[] ChannelTypes = { "email", "slack", "teams", "webhook", "none" };
public NotifySetupStep()
: base(
id: "notify",
name: "Notifications",
description: "Configure notification channels (Email, Slack, Teams, Webhook).",
category: SetupCategory.Integration,
order: 70,
isRequired: false,
validationChecks: new[] { "check.notify.channel.configured", "check.notify.channel.connectivity" })
{
}
public override async Task<SetupStepResult> ExecuteAsync(
SetupStepContext context,
CancellationToken ct = default)
{
Output(context, "Configuring notification channels...");
try
{
var appliedConfig = new Dictionary<string, string>();
var configuredChannels = new List<string>();
// Get channel type selection
var channelType = GetOrPromptChoice(
context,
"notify.channel",
"Select primary notification channel",
ChannelTypes,
"email");
if (channelType == "none")
{
Output(context, "Skipping notification configuration.");
return SetupStepResult.Skipped("User chose to skip notification setup");
}
appliedConfig["notify.channel"] = channelType;
// Configure the selected channel
var channelResult = channelType switch
{
"email" => await ConfigureEmailChannelAsync(context, appliedConfig, ct),
"slack" => await ConfigureSlackChannelAsync(context, appliedConfig, ct),
"teams" => await ConfigureTeamsChannelAsync(context, appliedConfig, ct),
"webhook" => await ConfigureWebhookChannelAsync(context, appliedConfig, ct),
_ => SetupStepResult.Failed($"Unknown channel type: {channelType}", canRetry: true)
};
if (!channelResult.IsSuccess)
{
return channelResult;
}
configuredChannels.Add(channelType);
// Ask if user wants to configure additional channels
while (true)
{
var addAnother = context.PromptForConfirmation("Configure another notification channel?", false);
if (!addAnother)
{
break;
}
var remainingChannels = ChannelTypes
.Where(c => c != "none" && !configuredChannels.Contains(c))
.Append("done")
.ToArray();
if (remainingChannels.Length == 1)
{
Output(context, "All channel types have been configured.");
break;
}
var nextChannel = context.PromptForChoice(
"Select channel to configure",
remainingChannels,
"done");
if (nextChannel == "done")
{
break;
}
var nextResult = nextChannel switch
{
"email" => await ConfigureEmailChannelAsync(context, appliedConfig, ct),
"slack" => await ConfigureSlackChannelAsync(context, appliedConfig, ct),
"teams" => await ConfigureTeamsChannelAsync(context, appliedConfig, ct),
"webhook" => await ConfigureWebhookChannelAsync(context, appliedConfig, ct),
_ => SetupStepResult.Success()
};
if (nextResult.IsSuccess)
{
configuredChannels.Add(nextChannel);
}
}
// Prompt for suggested event rules
await ConfigureEventRulesAsync(context, appliedConfig, ct);
Output(context, $"Configured {configuredChannels.Count} notification channel(s): {string.Join(", ", configuredChannels)}");
return SetupStepResult.Success(
$"Notification channels configured: {string.Join(", ", configuredChannels)}",
appliedConfig: appliedConfig);
}
catch (Exception ex)
{
OutputError(context, $"Notification setup failed: {ex.Message}");
return SetupStepResult.Failed(
$"Notification setup failed: {ex.Message}",
exception: ex,
canRetry: true);
}
}
private async Task<SetupStepResult> ConfigureEmailChannelAsync(
SetupStepContext context,
Dictionary<string, string> appliedConfig,
CancellationToken ct)
{
Output(context, "Configuring Email notifications...");
var smtpHost = GetOrPrompt(context, "notify.email.smtpHost", "SMTP server hostname");
var smtpPort = GetIntOrDefault(context, "notify.email.smtpPort", 587);
var useTls = GetBoolOrDefault(context, "notify.email.useTls", true);
var fromAddress = GetOrPrompt(context, "notify.email.fromAddress", "From email address");
var username = GetOrPrompt(context, "notify.email.username", "SMTP username (press Enter to skip)", "");
var password = string.IsNullOrEmpty(username) ? "" : GetOrPromptSecret(context, "notify.email.password", "SMTP password");
appliedConfig["Notify:Channels:Email:Enabled"] = "true";
appliedConfig["Notify:Channels:Email:SmtpHost"] = smtpHost;
appliedConfig["Notify:Channels:Email:SmtpPort"] = smtpPort.ToString();
appliedConfig["Notify:Channels:Email:UseTls"] = useTls.ToString().ToLowerInvariant();
appliedConfig["Notify:Channels:Email:FromAddress"] = fromAddress;
if (!string.IsNullOrEmpty(username))
{
appliedConfig["Notify:Channels:Email:Username"] = username;
appliedConfig["Notify:Channels:Email:Password"] = password;
}
if (context.DryRun)
{
Output(context, "[DRY RUN] Would configure Email notifications:");
Output(context, $" - SMTP: {smtpHost}:{smtpPort}");
Output(context, $" - TLS: {useTls}");
Output(context, $" - From: {fromAddress}");
return SetupStepResult.Success("Email channel prepared (dry run)", appliedConfig: appliedConfig);
}
// Test SMTP connectivity
Output(context, $"Testing SMTP connection to {smtpHost}:{smtpPort}...");
var testResult = await TestSmtpConnectionAsync(smtpHost, smtpPort, ct);
if (!testResult.Success)
{
OutputWarning(context, $"SMTP connection test failed: {testResult.Error}");
var proceed = context.PromptForConfirmation("Continue anyway?", false);
if (!proceed)
{
return SetupStepResult.Failed($"SMTP connection failed: {testResult.Error}", canRetry: true);
}
}
else
{
Output(context, "SMTP connection successful.");
}
return SetupStepResult.Success("Email channel configured", appliedConfig: appliedConfig);
}
private Task<SetupStepResult> ConfigureSlackChannelAsync(
SetupStepContext context,
Dictionary<string, string> appliedConfig,
CancellationToken ct)
{
Output(context, "Configuring Slack notifications...");
var webhookUrl = GetOrPromptSecret(context, "notify.slack.webhookUrl", "Slack webhook URL");
appliedConfig["Notify:Channels:Slack:Enabled"] = "true";
appliedConfig["Notify:Channels:Slack:WebhookUrl"] = webhookUrl;
if (context.DryRun)
{
Output(context, "[DRY RUN] Would configure Slack notifications with provided webhook URL");
return Task.FromResult(SetupStepResult.Success("Slack channel prepared (dry run)", appliedConfig: appliedConfig));
}
Output(context, "Slack channel configured.");
return Task.FromResult(SetupStepResult.Success("Slack channel configured", appliedConfig: appliedConfig));
}
private Task<SetupStepResult> ConfigureTeamsChannelAsync(
SetupStepContext context,
Dictionary<string, string> appliedConfig,
CancellationToken ct)
{
Output(context, "Configuring Microsoft Teams notifications...");
var webhookUrl = GetOrPromptSecret(context, "notify.teams.webhookUrl", "Teams webhook URL");
appliedConfig["Notify:Channels:Teams:Enabled"] = "true";
appliedConfig["Notify:Channels:Teams:WebhookUrl"] = webhookUrl;
if (context.DryRun)
{
Output(context, "[DRY RUN] Would configure Teams notifications with provided webhook URL");
return Task.FromResult(SetupStepResult.Success("Teams channel prepared (dry run)", appliedConfig: appliedConfig));
}
Output(context, "Teams channel configured.");
return Task.FromResult(SetupStepResult.Success("Teams channel configured", appliedConfig: appliedConfig));
}
private Task<SetupStepResult> ConfigureWebhookChannelAsync(
SetupStepContext context,
Dictionary<string, string> appliedConfig,
CancellationToken ct)
{
Output(context, "Configuring custom webhook notifications...");
var endpoint = GetOrPrompt(context, "notify.webhook.endpoint", "Webhook endpoint URL");
var secret = GetOrPromptSecret(context, "notify.webhook.secret", "Webhook secret (for signature verification, press Enter to skip)");
appliedConfig["Notify:Channels:Webhook:Enabled"] = "true";
appliedConfig["Notify:Channels:Webhook:Endpoint"] = endpoint;
if (!string.IsNullOrEmpty(secret))
{
appliedConfig["Notify:Channels:Webhook:Secret"] = secret;
}
if (context.DryRun)
{
Output(context, "[DRY RUN] Would configure webhook notifications:");
Output(context, $" - Endpoint: {endpoint}");
return Task.FromResult(SetupStepResult.Success("Webhook channel prepared (dry run)", appliedConfig: appliedConfig));
}
Output(context, "Webhook channel configured.");
return Task.FromResult(SetupStepResult.Success("Webhook channel configured", appliedConfig: appliedConfig));
}
private Task ConfigureEventRulesAsync(
SetupStepContext context,
Dictionary<string, string> appliedConfig,
CancellationToken ct)
{
Output(context, "\n--- Suggested Event Rules ---");
Output(context, "The following default notification rules are recommended:");
Output(context, " 1. Scan Failure Alert - Notify immediately when scans fail");
Output(context, " 2. Scan Success Summary - Daily digest of successful scans");
Output(context, " 3. Deploy to Production - Alert on production deployments");
Output(context, " 4. Deploy Failure - Critical alert on deployment failures");
var enableDefaults = context.PromptForConfirmation("Enable these default notification rules?", true);
if (enableDefaults)
{
appliedConfig["Notify:Rules:ScanFailure:Enabled"] = "true";
appliedConfig["Notify:Rules:ScanFailure:EventKinds"] = "scanner.report.ready";
appliedConfig["Notify:Rules:ScanFailure:Condition"] = "status == 'failed'";
appliedConfig["Notify:Rules:ScanFailure:Severity"] = "critical";
appliedConfig["Notify:Rules:ScanSuccess:Enabled"] = "true";
appliedConfig["Notify:Rules:ScanSuccess:EventKinds"] = "scanner.report.ready";
appliedConfig["Notify:Rules:ScanSuccess:Condition"] = "status == 'passed'";
appliedConfig["Notify:Rules:ScanSuccess:Digest"] = "daily";
appliedConfig["Notify:Rules:DeployProd:Enabled"] = "true";
appliedConfig["Notify:Rules:DeployProd:EventKinds"] = "workflow.step.completed";
appliedConfig["Notify:Rules:DeployProd:Condition"] = "environment == 'production'";
appliedConfig["Notify:Rules:DeployProd:Severity"] = "warning";
appliedConfig["Notify:Rules:DeployFailure:Enabled"] = "true";
appliedConfig["Notify:Rules:DeployFailure:EventKinds"] = "workflow.step.failed";
appliedConfig["Notify:Rules:DeployFailure:Severity"] = "critical";
Output(context, "Default notification rules enabled.");
}
else
{
Output(context, "Skipped default notification rules. You can configure rules manually later.");
}
return Task.CompletedTask;
}
public override Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
SetupStepContext context,
CancellationToken ct = default)
{
// Notify setup has no prerequisites
return Task.FromResult(SetupStepPrerequisiteResult.Success());
}
public override async Task<SetupStepValidationResult> ValidateAsync(
SetupStepContext context,
CancellationToken ct = default)
{
var warnings = new List<string>();
// Validate Email configuration if enabled
if (context.ConfigValues.TryGetValue("Notify:Channels:Email:Enabled", out var emailEnabled) &&
emailEnabled == "true")
{
var smtpHost = context.ConfigValues.GetValueOrDefault("Notify:Channels:Email:SmtpHost");
if (string.IsNullOrEmpty(smtpHost))
{
return SetupStepValidationResult.Failed(
"Email channel enabled but SMTP host not configured",
errors: new[] { "Notify:Channels:Email:SmtpHost is not set" });
}
var port = int.TryParse(
context.ConfigValues.GetValueOrDefault("Notify:Channels:Email:SmtpPort"),
out var p) ? p : 587;
var testResult = await TestSmtpConnectionAsync(smtpHost, port, ct);
if (!testResult.Success)
{
warnings.Add($"Email connectivity issue: {testResult.Error}");
}
}
// Validate Slack configuration if enabled
if (context.ConfigValues.TryGetValue("Notify:Channels:Slack:Enabled", out var slackEnabled) &&
slackEnabled == "true")
{
var webhookUrl = context.ConfigValues.GetValueOrDefault("Notify:Channels:Slack:WebhookUrl");
if (string.IsNullOrEmpty(webhookUrl))
{
return SetupStepValidationResult.Failed(
"Slack channel enabled but webhook URL not configured",
errors: new[] { "Notify:Channels:Slack:WebhookUrl is not set" });
}
}
if (warnings.Count > 0)
{
return SetupStepValidationResult.Warning(
"Notification configuration has warnings",
warnings: warnings);
}
return SetupStepValidationResult.Success("Notification channels configured");
}
private static async Task<(bool Success, string? Error)> TestSmtpConnectionAsync(
string host,
int port,
CancellationToken ct)
{
try
{
using var client = new TcpClient();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(10));
await client.ConnectAsync(host, port, cts.Token);
return (true, null);
}
catch (OperationCanceledException)
{
return (false, "Connection timed out");
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
private string GetOrPromptChoice(
SetupStepContext context,
string key,
string prompt,
string[] options,
string defaultValue)
{
if (context.ConfigValues.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
{
return value;
}
return context.PromptForChoice(prompt, options, defaultValue);
}
}

View File

@@ -0,0 +1,228 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
/// <summary>
/// Setup step for container registry configuration.
/// </summary>
public sealed class RegistrySetupStep : SetupStepBase
{
public RegistrySetupStep()
: base(
id: "registry",
name: "Container Registry",
description: "Configure the container registry for storing and retrieving container images.",
category: SetupCategory.Integration,
order: 10,
isRequired: false,
validationChecks: new[]
{
"check.integration.registry.connectivity",
"check.integration.registry.auth",
"check.integration.registry.push"
})
{
}
public override async Task<SetupStepResult> ExecuteAsync(
SetupStepContext context,
CancellationToken ct = default)
{
Output(context, "Configuring container registry...");
try
{
var url = GetOrPrompt(context, "registry.url", "Registry URL", null);
if (string.IsNullOrEmpty(url))
{
if (context.NonInteractive)
{
return SetupStepResult.Skipped("No registry URL provided in non-interactive mode");
}
if (!context.PromptForConfirmation("Skip registry configuration?", true))
{
url = context.PromptForInput("Registry URL", null);
}
}
if (string.IsNullOrEmpty(url))
{
return SetupStepResult.Skipped("Registry configuration skipped");
}
// Normalize URL
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
url = $"https://{url}";
}
var username = GetOrPrompt(context, "registry.username", "Registry username (leave empty for anonymous)", "");
var password = string.Empty;
if (!string.IsNullOrEmpty(username))
{
password = GetOrPromptSecret(context, "registry.password", "Registry password");
}
var insecure = GetBoolOrDefault(context, "registry.insecure", false);
if (insecure)
{
OutputWarning(context, "Insecure mode enabled - TLS verification will be skipped");
}
if (context.DryRun)
{
Output(context, $"[DRY RUN] Would configure registry at {url}");
return SetupStepResult.Success(
"Registry configuration prepared (dry run)",
appliedConfig: new Dictionary<string, string>
{
["registry.url"] = url,
["registry.username"] = username,
["registry.insecure"] = insecure.ToString().ToLowerInvariant()
});
}
// Test connection
Output(context, $"Testing connection to {url}...");
var registryInfo = await TestRegistryConnectionAsync(url, username, password, insecure, ct);
Output(context, "Registry connection successful.");
if (!string.IsNullOrEmpty(registryInfo.Version))
{
OutputVerbose(context, $"Registry API version: {registryInfo.Version}");
}
var appliedConfig = new Dictionary<string, string>
{
["registry.url"] = url,
["registry.insecure"] = insecure.ToString().ToLowerInvariant()
};
if (!string.IsNullOrEmpty(username))
{
appliedConfig["registry.username"] = username;
// Password stored securely, not in plain config
}
var outputValues = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(registryInfo.Version))
{
outputValues["registry.apiVersion"] = registryInfo.Version;
}
return SetupStepResult.Success(
$"Registry configured: {url}",
outputValues: outputValues,
appliedConfig: appliedConfig);
}
catch (HttpRequestException ex)
{
OutputError(context, $"Registry connection failed: {ex.Message}");
return SetupStepResult.Failed(
$"Failed to connect to registry: {ex.Message}",
exception: ex,
canRetry: true);
}
catch (Exception ex)
{
OutputError(context, $"Registry setup failed: {ex.Message}");
return SetupStepResult.Failed(
$"Registry setup failed: {ex.Message}",
exception: ex,
canRetry: true);
}
}
public override async Task<SetupStepValidationResult> ValidateAsync(
SetupStepContext context,
CancellationToken ct = default)
{
if (!context.ConfigValues.TryGetValue("registry.url", out var url) || string.IsNullOrEmpty(url))
{
// Registry is optional, so no URL is valid
return SetupStepValidationResult.Success("Registry not configured (optional)");
}
try
{
var username = context.ConfigValues.TryGetValue("registry.username", out var u) ? u : null;
var password = context.ConfigValues.TryGetValue("registry.password", out var p) ? p : null;
var insecure = GetBoolOrDefault(context, "registry.insecure", false);
await TestRegistryConnectionAsync(url, username, password, insecure, ct);
return SetupStepValidationResult.Success("Registry connection validated");
}
catch (Exception ex)
{
return SetupStepValidationResult.Failed(
"Registry connection validation failed",
errors: new[] { ex.Message });
}
}
private static async Task<RegistryInfo> TestRegistryConnectionAsync(
string url,
string? username,
string? password,
bool insecure,
CancellationToken ct)
{
var handler = new HttpClientHandler();
if (insecure)
{
handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;
}
using var client = new HttpClient(handler);
client.BaseAddress = new Uri(url.TrimEnd('/'));
client.Timeout = TimeSpan.FromSeconds(30);
// Add basic auth if credentials provided
if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password))
{
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials);
}
// Try OCI Distribution API v2
var response = await client.GetAsync("/v2/", ct);
// 401 is expected for auth-required registries (need to check WWW-Authenticate header)
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
if (string.IsNullOrEmpty(username))
{
// Anonymous access not allowed, but registry is reachable
return new RegistryInfo { Version = "v2", RequiresAuth = true };
}
throw new HttpRequestException("Authentication failed - check username and password");
}
response.EnsureSuccessStatusCode();
// Check Docker-Distribution-Api-Version header
var apiVersion = response.Headers.TryGetValues("Docker-Distribution-Api-Version", out var versions)
? string.Join(", ", versions)
: null;
return new RegistryInfo { Version = apiVersion ?? "v2" };
}
private sealed record RegistryInfo
{
public string? Version { get; init; }
public bool RequiresAuth { get; init; }
}
}

View File

@@ -0,0 +1,422 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
/// <summary>
/// Setup step for settings store configuration.
/// Supports Consul KV, etcd, Azure App Configuration, AWS Parameter Store, and AWS AppConfig.
/// </summary>
public sealed class SettingsStoreSetupStep : SetupStepBase
{
private static readonly string[] SupportedProviders = { "consul", "etcd", "azure", "aws-parameter-store", "aws-appconfig" };
public SettingsStoreSetupStep()
: base(
id: "settingsstore",
name: "Settings Store",
description: "Configure a settings store for application configuration and feature flags (Consul, etcd, Azure App Configuration, or AWS Parameter Store).",
category: SetupCategory.Configuration,
order: 10,
isRequired: false,
validationChecks: new[]
{
"check.integration.settingsstore.connectivity",
"check.integration.settingsstore.auth",
"check.integration.settingsstore.read"
})
{
}
public override async Task<SetupStepResult> ExecuteAsync(
SetupStepContext context,
CancellationToken ct = default)
{
Output(context, "Configuring settings store...");
try
{
// Select provider
var provider = GetOrSelectProvider(context);
if (string.IsNullOrEmpty(provider))
{
return SetupStepResult.Skipped("No settings store provider selected");
}
Output(context, $"Configuring {GetProviderDisplayName(provider)} settings store...");
var result = provider.ToLowerInvariant() switch
{
"consul" => await ConfigureConsulKvAsync(context, ct),
"etcd" => await ConfigureEtcdAsync(context, ct),
"azure" => await ConfigureAzureAppConfigAsync(context, ct),
"aws-parameter-store" => await ConfigureAwsParameterStoreAsync(context, ct),
"aws-appconfig" => await ConfigureAwsAppConfigAsync(context, ct),
_ => SetupStepResult.Failed($"Unsupported settings store provider: {provider}")
};
return result;
}
catch (Exception ex)
{
OutputError(context, $"Settings store setup failed: {ex.Message}");
return SetupStepResult.Failed(
$"Settings store setup failed: {ex.Message}",
exception: ex,
canRetry: true);
}
}
private async Task<SetupStepResult> ConfigureConsulKvAsync(
SetupStepContext context,
CancellationToken ct)
{
var address = GetOrPrompt(context, "settingsstore.address", "Consul address",
Environment.GetEnvironmentVariable("CONSUL_HTTP_ADDR") ?? "http://localhost:8500");
var prefix = GetOrPrompt(context, "settingsstore.prefix", "Key prefix", "stellaops/config/");
var token = context.ConfigValues.TryGetValue("settingsstore.token", out var t) ? t :
Environment.GetEnvironmentVariable("CONSUL_HTTP_TOKEN");
var reloadOnChange = GetBoolOrDefault(context, "settingsstore.reloadOnChange", true);
if (context.DryRun)
{
Output(context, $"[DRY RUN] Would configure Consul KV at {address}");
return SetupStepResult.Success(
"Consul KV configuration prepared (dry run)",
appliedConfig: new Dictionary<string, string>
{
["settingsstore.provider"] = "consul",
["settingsstore.address"] = address,
["settingsstore.prefix"] = prefix,
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
});
}
// Test connection
Output(context, $"Testing connection to Consul at {address}...");
var leader = await TestConsulConnectionAsync(address, token, ct);
Output(context, "Consul connection successful.");
OutputVerbose(context, $"Consul leader: {leader}");
return SetupStepResult.Success(
$"Consul KV configured at {address} with prefix '{prefix}'",
outputValues: new Dictionary<string, string> { ["settingsstore.leader"] = leader },
appliedConfig: new Dictionary<string, string>
{
["settingsstore.provider"] = "consul",
["settingsstore.address"] = address,
["settingsstore.prefix"] = prefix,
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
});
}
private async Task<SetupStepResult> ConfigureEtcdAsync(
SetupStepContext context,
CancellationToken ct)
{
var endpoints = GetOrPrompt(context, "settingsstore.address", "etcd endpoints (comma-separated)",
Environment.GetEnvironmentVariable("ETCD_ENDPOINTS") ?? "http://localhost:2379");
var prefix = GetOrPrompt(context, "settingsstore.prefix", "Key prefix", "/stellaops/config/");
var reloadOnChange = GetBoolOrDefault(context, "settingsstore.reloadOnChange", true);
if (context.DryRun)
{
Output(context, $"[DRY RUN] Would configure etcd at {endpoints}");
return SetupStepResult.Success(
"etcd configuration prepared (dry run)",
appliedConfig: new Dictionary<string, string>
{
["settingsstore.provider"] = "etcd",
["settingsstore.address"] = endpoints,
["settingsstore.prefix"] = prefix,
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
});
}
// Test connection to first endpoint
Output(context, $"Testing connection to etcd...");
var firstEndpoint = endpoints.Split(',')[0].Trim();
var version = await TestEtcdConnectionAsync(firstEndpoint, ct);
Output(context, "etcd connection successful.");
OutputVerbose(context, $"etcd version: {version}");
return SetupStepResult.Success(
$"etcd configured with prefix '{prefix}'",
outputValues: new Dictionary<string, string> { ["settingsstore.version"] = version },
appliedConfig: new Dictionary<string, string>
{
["settingsstore.provider"] = "etcd",
["settingsstore.address"] = endpoints,
["settingsstore.prefix"] = prefix,
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
});
}
private Task<SetupStepResult> ConfigureAzureAppConfigAsync(
SetupStepContext context,
CancellationToken ct)
{
var connectionString = context.ConfigValues.TryGetValue("settingsstore.connectionString", out var cs) ? cs : null;
var endpoint = context.ConfigValues.TryGetValue("settingsstore.address", out var ep) ? ep : null;
if (string.IsNullOrEmpty(connectionString) && string.IsNullOrEmpty(endpoint))
{
if (!context.NonInteractive)
{
var useConnectionString = context.PromptForConfirmation(
"Use connection string? (No = use Managed Identity)", true);
if (useConnectionString)
{
connectionString = context.PromptForSecret("Azure App Configuration connection string");
}
else
{
endpoint = context.PromptForInput("Azure App Configuration endpoint", null);
}
}
else
{
return Task.FromResult(SetupStepResult.Failed(
"Azure App Configuration connection string or endpoint required"));
}
}
var label = GetOrPrompt(context, "settingsstore.label", "Configuration label (e.g., dev, prod)", "");
var reloadOnChange = GetBoolOrDefault(context, "settingsstore.reloadOnChange", true);
if (context.DryRun)
{
Output(context, $"[DRY RUN] Would configure Azure App Configuration");
return Task.FromResult(SetupStepResult.Success(
"Azure App Configuration prepared (dry run)",
appliedConfig: new Dictionary<string, string>
{
["settingsstore.provider"] = "azure",
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
}));
}
Output(context, "Azure App Configuration configured.");
if (!string.IsNullOrEmpty(endpoint))
{
Output(context, "Using Managed Identity for authentication.");
}
var appliedConfig = new Dictionary<string, string>
{
["settingsstore.provider"] = "azure",
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
};
if (!string.IsNullOrEmpty(label))
{
appliedConfig["settingsstore.label"] = label;
}
if (!string.IsNullOrEmpty(endpoint))
{
appliedConfig["settingsstore.address"] = endpoint;
}
return Task.FromResult(SetupStepResult.Success(
"Azure App Configuration configured",
appliedConfig: appliedConfig));
}
private Task<SetupStepResult> ConfigureAwsParameterStoreAsync(
SetupStepContext context,
CancellationToken ct)
{
var region = GetOrPrompt(context, "settingsstore.region", "AWS Region",
Environment.GetEnvironmentVariable("AWS_REGION") ?? "us-east-1");
var prefix = GetOrPrompt(context, "settingsstore.prefix", "Parameter path prefix", "/stellaops/");
var reloadOnChange = GetBoolOrDefault(context, "settingsstore.reloadOnChange", true);
if (context.DryRun)
{
Output(context, $"[DRY RUN] Would configure AWS Parameter Store in {region}");
return Task.FromResult(SetupStepResult.Success(
"AWS Parameter Store configuration prepared (dry run)",
appliedConfig: new Dictionary<string, string>
{
["settingsstore.provider"] = "aws-parameter-store",
["settingsstore.region"] = region,
["settingsstore.prefix"] = prefix,
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
}));
}
Output(context, "AWS Parameter Store will use default credentials chain.");
return Task.FromResult(SetupStepResult.Success(
$"AWS Parameter Store configured in {region} with prefix '{prefix}'",
appliedConfig: new Dictionary<string, string>
{
["settingsstore.provider"] = "aws-parameter-store",
["settingsstore.region"] = region,
["settingsstore.prefix"] = prefix,
["settingsstore.reloadOnChange"] = reloadOnChange.ToString().ToLowerInvariant()
}));
}
private Task<SetupStepResult> ConfigureAwsAppConfigAsync(
SetupStepContext context,
CancellationToken ct)
{
var region = GetOrPrompt(context, "settingsstore.region", "AWS Region",
Environment.GetEnvironmentVariable("AWS_REGION") ?? "us-east-1");
var application = GetOrPrompt(context, "settingsstore.application", "AppConfig Application ID", null);
var environment = GetOrPrompt(context, "settingsstore.environment", "AppConfig Environment ID", null);
var configuration = GetOrPrompt(context, "settingsstore.configuration", "AppConfig Configuration Profile ID", null);
if (string.IsNullOrEmpty(application) || string.IsNullOrEmpty(environment) || string.IsNullOrEmpty(configuration))
{
return Task.FromResult(SetupStepResult.Failed(
"AWS AppConfig requires application, environment, and configuration profile IDs"));
}
if (context.DryRun)
{
Output(context, $"[DRY RUN] Would configure AWS AppConfig in {region}");
return Task.FromResult(SetupStepResult.Success(
"AWS AppConfig configuration prepared (dry run)",
appliedConfig: new Dictionary<string, string>
{
["settingsstore.provider"] = "aws-appconfig",
["settingsstore.region"] = region,
["settingsstore.application"] = application,
["settingsstore.environment"] = environment,
["settingsstore.configuration"] = configuration
}));
}
Output(context, "AWS AppConfig will use default credentials chain.");
return Task.FromResult(SetupStepResult.Success(
$"AWS AppConfig configured for {application}/{environment}/{configuration}",
appliedConfig: new Dictionary<string, string>
{
["settingsstore.provider"] = "aws-appconfig",
["settingsstore.region"] = region,
["settingsstore.application"] = application,
["settingsstore.environment"] = environment,
["settingsstore.configuration"] = configuration
}));
}
private string? GetOrSelectProvider(SetupStepContext context)
{
if (context.ConfigValues.TryGetValue("settingsstore.provider", out var provider))
{
return provider;
}
// Auto-detect from environment
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CONSUL_HTTP_ADDR")))
{
if (!context.NonInteractive)
{
Output(context, "Detected Consul from CONSUL_HTTP_ADDR environment variable.");
}
return "consul";
}
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ETCD_ENDPOINTS")))
{
if (!context.NonInteractive)
{
Output(context, "Detected etcd from ETCD_ENDPOINTS environment variable.");
}
return "etcd";
}
if (context.NonInteractive)
{
return null;
}
var options = new List<string>
{
"Consul KV",
"etcd",
"Azure App Configuration",
"AWS Parameter Store",
"AWS AppConfig",
"Skip (no settings store)"
};
var selection = context.PromptForSelection("Select settings store provider", options);
return selection switch
{
0 => "consul",
1 => "etcd",
2 => "azure",
3 => "aws-parameter-store",
4 => "aws-appconfig",
_ => null
};
}
private static string GetProviderDisplayName(string provider)
{
return provider.ToLowerInvariant() switch
{
"consul" => "Consul KV",
"etcd" => "etcd",
"azure" => "Azure App Configuration",
"aws-parameter-store" => "AWS Parameter Store",
"aws-appconfig" => "AWS AppConfig",
_ => provider
};
}
private static async Task<string> TestConsulConnectionAsync(
string address,
string? token,
CancellationToken ct)
{
using var client = new HttpClient();
client.BaseAddress = new Uri(address.TrimEnd('/'));
client.Timeout = TimeSpan.FromSeconds(10);
if (!string.IsNullOrEmpty(token))
{
client.DefaultRequestHeaders.Add("X-Consul-Token", token);
}
var response = await client.GetAsync("/v1/status/leader", ct);
response.EnsureSuccessStatusCode();
var leader = await response.Content.ReadAsStringAsync(ct);
return leader.Trim('"');
}
private static async Task<string> TestEtcdConnectionAsync(string endpoint, CancellationToken ct)
{
using var client = new HttpClient();
client.BaseAddress = new Uri(endpoint.TrimEnd('/'));
client.Timeout = TimeSpan.FromSeconds(10);
var response = await client.GetAsync("/version", ct);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(ct);
var versionInfo = JsonSerializer.Deserialize<EtcdVersionInfo>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return versionInfo?.EtcdServer ?? "Unknown";
}
private sealed class EtcdVersionInfo
{
public string? EtcdServer { get; set; }
public string? EtcdCluster { get; set; }
}
}

View File

@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
/// <summary>
/// Base class for setup steps with common functionality.
/// </summary>
public abstract class SetupStepBase : ISetupStep
{
protected SetupStepBase(
string id,
string name,
string description,
SetupCategory category,
int order,
bool isRequired = false,
IReadOnlyList<string>? dependencies = null,
IReadOnlyList<string>? validationChecks = null)
{
Id = id;
Name = name;
Description = description;
Category = category;
Order = order;
IsRequired = isRequired;
Dependencies = dependencies ?? Array.Empty<string>();
ValidationChecks = validationChecks ?? Array.Empty<string>();
}
public string Id { get; }
public string Name { get; }
public string Description { get; }
public SetupCategory Category { get; }
public int Order { get; }
public bool IsRequired { get; }
public bool IsSkippable => !IsRequired;
public IReadOnlyList<string> Dependencies { get; }
public IReadOnlyList<string> ValidationChecks { get; }
public virtual Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
SetupStepContext context,
CancellationToken ct = default)
{
return Task.FromResult(SetupStepPrerequisiteResult.Success());
}
public abstract Task<SetupStepResult> ExecuteAsync(
SetupStepContext context,
CancellationToken ct = default);
public virtual Task<SetupStepValidationResult> ValidateAsync(
SetupStepContext context,
CancellationToken ct = default)
{
return Task.FromResult(SetupStepValidationResult.Success());
}
public virtual Task<SetupStepRollbackResult> RollbackAsync(
SetupStepContext context,
CancellationToken ct = default)
{
return Task.FromResult(SetupStepRollbackResult.NotSupported());
}
/// <summary>
/// Gets a configuration value from context or prompts user if not available.
/// </summary>
protected string GetOrPrompt(
SetupStepContext context,
string key,
string prompt,
string? defaultValue = null)
{
if (context.ConfigValues.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
{
return value;
}
return context.PromptForInput(prompt, defaultValue);
}
/// <summary>
/// Gets a secret from context or prompts user if not available.
/// </summary>
protected string GetOrPromptSecret(
SetupStepContext context,
string key,
string prompt)
{
if (context.ConfigValues.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
{
return value;
}
return context.PromptForSecret(prompt);
}
/// <summary>
/// Gets an integer configuration value with default.
/// </summary>
protected int GetIntOrDefault(
SetupStepContext context,
string key,
int defaultValue)
{
if (context.ConfigValues.TryGetValue(key, out var value) && int.TryParse(value, out var result))
{
return result;
}
return defaultValue;
}
/// <summary>
/// Gets a boolean configuration value with default.
/// </summary>
protected bool GetBoolOrDefault(
SetupStepContext context,
string key,
bool defaultValue)
{
if (context.ConfigValues.TryGetValue(key, out var value) && bool.TryParse(value, out var result))
{
return result;
}
return defaultValue;
}
/// <summary>
/// Outputs a step message to the console.
/// </summary>
protected void Output(SetupStepContext context, string message)
{
context.Output(message);
}
/// <summary>
/// Outputs a warning message to the console.
/// </summary>
protected void OutputWarning(SetupStepContext context, string message)
{
context.OutputWarning(message);
}
/// <summary>
/// Outputs an error message to the console.
/// </summary>
protected void OutputError(SetupStepContext context, string message)
{
context.OutputError(message);
}
/// <summary>
/// Outputs a verbose message if verbose mode is enabled.
/// </summary>
protected void OutputVerbose(SetupStepContext context, string message)
{
if (context.Verbose)
{
context.Output($" [verbose] {message}");
}
}
/// <summary>
/// Prompts for confirmation with default value of true.
/// </summary>
protected bool PromptForConfirmation(SetupStepContext context, string prompt, bool defaultValue = true)
{
return context.PromptForConfirmation(prompt, defaultValue);
}
/// <summary>
/// Gets a string configuration value with default.
/// </summary>
protected string GetOrDefault(SetupStepContext context, string key, string defaultValue)
{
return context.ConfigValues.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value)
? value
: defaultValue;
}
}

View File

@@ -0,0 +1,420 @@
// -----------------------------------------------------------------------------
// SourcesSetupStep.cs
// Sprint: SPRINT_20260114_SOURCES_SETUP
// Task: 12.2 - Sources Setup Step
// Description: CLI setup step for configuring advisory data sources
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Concelier.Core.Sources;
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
/// <summary>
/// Setup step for configuring advisory data sources.
/// Runs connectivity checks and auto-enables healthy sources.
/// </summary>
public sealed class SourcesSetupStep : SetupStepBase
{
private readonly ISourceRegistry? _sourceRegistry;
/// <summary>
/// Creates a new sources setup step.
/// </summary>
/// <param name="sourceRegistry">Optional source registry for connectivity checks.</param>
public SourcesSetupStep(ISourceRegistry? sourceRegistry = null)
: base(
id: "sources",
name: "Advisory Sources",
description: "Configure CVE/advisory data sources with automatic connectivity detection.",
category: SetupCategory.Data,
order: 10,
isRequired: false,
validationChecks: new[]
{
"check.sources.mode.configured"
})
{
_sourceRegistry = sourceRegistry;
}
public override async Task<SetupStepResult> ExecuteAsync(
SetupStepContext context,
CancellationToken ct = default)
{
Output(context, "");
Output(context, "Advisory Sources Configuration");
Output(context, "==============================");
Output(context, "");
Output(context, "All sources are enabled by default.");
Output(context, "Running connectivity checks to detect availability...");
Output(context, "");
// Use injected source registry
var registry = _sourceRegistry;
if (registry is null)
{
OutputWarning(context, "Source registry not available. Using default configuration.");
return SetupStepResult.Success(
"Default sources configuration applied",
appliedConfig: new Dictionary<string, string>
{
["sources.mode"] = "mirror"
});
}
try
{
// Run connectivity checks for all sources
var checkResult = await registry.CheckAllAndAutoConfigureAsync(ct);
// Display results
DisplayConnectivityResults(context, checkResult, registry);
// Handle failed sources
if (checkResult.FailedCount > 0)
{
Output(context, "");
Output(context, $"[!] {checkResult.FailedCount} source(s) failed connectivity check:");
Output(context, "");
foreach (var failed in checkResult.Results.Where(r => !r.IsHealthy))
{
DisplaySourceError(context, failed, registry);
}
if (!context.NonInteractive)
{
Output(context, "");
var action = context.PromptForSelection(
"How would you like to proceed?",
new List<string>
{
"Auto-disable failed sources and continue",
"Review and fix issues (I'll configure manually)",
"Enable all anyway (may cause sync errors)"
});
switch (action)
{
case 0:
await DisableFailedSourcesAsync(context, checkResult, registry, ct);
break;
case 1:
return SetupStepResult.Skipped(
"Review source configuration and run 'stella sources check' when ready.");
case 2:
Output(context, "Keeping all sources enabled. Sync errors may occur.");
break;
}
}
else
{
// Non-interactive mode: auto-disable failed sources
await DisableFailedSourcesAsync(context, checkResult, registry, ct);
}
}
// Configure source mode
var mode = ConfigureSourceMode(context);
// Configure mirror server if selected
var mirrorConfig = new Dictionary<string, string>();
if (mode == "mirror" || mode == "hybrid")
{
mirrorConfig = await ConfigureMirrorServerAsync(context, ct);
}
// Build applied configuration
var appliedConfig = new Dictionary<string, string>
{
["sources.mode"] = mode,
["sources.autoEnableHealthy"] = "true"
};
foreach (var (key, value) in mirrorConfig)
{
appliedConfig[key] = value;
}
// Add source states
var enabledSources = await registry.GetEnabledSourcesAsync(ct);
appliedConfig["sources.enabledCount"] = enabledSources.Length.ToString(CultureInfo.InvariantCulture);
Output(context, "");
Output(context, $"Sources configured: {enabledSources.Length} enabled, {checkResult.FailedCount} disabled");
return SetupStepResult.Success(
$"Configured {enabledSources.Length} advisory source(s)",
appliedConfig: appliedConfig);
}
catch (Exception ex)
{
OutputError(context, $"Source configuration failed: {ex.Message}");
return SetupStepResult.Failed(
$"Failed to configure sources: {ex.Message}",
exception: ex,
canRetry: true);
}
}
private void DisplayConnectivityResults(
SetupStepContext context,
SourceCheckResult checkResult,
ISourceRegistry registry)
{
Output(context, $"Checked {checkResult.TotalChecked} sources in {checkResult.TotalDuration.TotalSeconds:F1}s");
Output(context, "");
// Group by status
foreach (var result in checkResult.Results.OrderBy(r => !r.IsHealthy).ThenBy(r => r.SourceId))
{
var source = registry.GetSource(result.SourceId);
var displayName = source?.DisplayName ?? result.SourceId;
var statusIcon = result.Status switch
{
SourceConnectivityStatus.Healthy => "[OK]",
SourceConnectivityStatus.Degraded => "[WARN]",
SourceConnectivityStatus.Failed => "[FAIL]",
_ => "[?]"
};
var latencyInfo = result.Latency.HasValue
? $" ({result.Latency.Value.TotalMilliseconds:F0}ms)"
: "";
Output(context, $" {statusIcon} {displayName}{latencyInfo}");
if (context.Verbose && !string.IsNullOrEmpty(result.ErrorMessage))
{
Output(context, $" {result.ErrorMessage}");
}
}
Output(context, "");
Output(context, $"Summary: {checkResult.HealthyCount}/{checkResult.TotalChecked} healthy, {checkResult.FailedCount} failed");
}
private void DisplaySourceError(
SetupStepContext context,
SourceConnectivityResult result,
ISourceRegistry registry)
{
var source = registry.GetSource(result.SourceId);
var displayName = source?.DisplayName ?? result.SourceId;
Output(context, $" Source: {displayName}");
Output(context, $" Error: {result.ErrorMessage}");
if (result.PossibleReasons.Length > 0)
{
Output(context, "");
Output(context, " Why this might be happening:");
foreach (var reason in result.PossibleReasons)
{
Output(context, $" - {reason}");
}
}
if (result.RemediationSteps.Length > 0)
{
Output(context, "");
Output(context, " How to fix:");
foreach (var step in result.RemediationSteps)
{
Output(context, $" {step.Order}. {step.Description}");
if (!string.IsNullOrEmpty(step.Command))
{
Output(context, $" $ {step.Command}");
}
}
}
Output(context, "");
}
private async Task DisableFailedSourcesAsync(
SetupStepContext context,
SourceCheckResult checkResult,
ISourceRegistry registry,
CancellationToken ct)
{
Output(context, "Disabling failed sources...");
foreach (var result in checkResult.Results.Where(r => !r.IsHealthy))
{
await registry.DisableSourceAsync(result.SourceId, ct);
OutputVerbose(context, $" Disabled: {result.SourceId}");
}
Output(context, $"Disabled {checkResult.FailedCount} source(s)");
}
private string ConfigureSourceMode(SetupStepContext context)
{
if (context.ConfigValues.TryGetValue("sources.mode", out var configuredMode) &&
!string.IsNullOrEmpty(configuredMode))
{
return configuredMode.ToLowerInvariant();
}
if (context.NonInteractive)
{
return "mirror"; // Default in non-interactive mode
}
var options = new[]
{
"Mirror - Use StellaOps pre-aggregated feeds (recommended)",
"Direct - Connect to upstream sources directly",
"Hybrid - Mirror with direct fallback"
};
var choice = context.PromptForSelection("Select source mode:", options);
return choice switch
{
0 => "mirror",
1 => "direct",
2 => "hybrid",
_ => "mirror"
};
}
private Task<Dictionary<string, string>> ConfigureMirrorServerAsync(
SetupStepContext context,
CancellationToken ct)
{
var config = new Dictionary<string, string>();
// Check if user wants to expose as mirror server
if (!context.NonInteractive)
{
var exposeMirror = context.PromptForConfirmation(
"Expose this instance as a mirror server for other instances?",
false);
if (!exposeMirror)
{
return Task.FromResult(config);
}
}
else if (!GetBoolOrDefault(context, "sources.mirrorServer.enabled", false))
{
return Task.FromResult(config);
}
config["sources.mirrorServer.enabled"] = "true";
// Export root
var exportRoot = GetOrPrompt(
context,
"sources.mirrorServer.exportRoot",
"Mirror export directory",
"./exports/mirror");
config["sources.mirrorServer.exportRoot"] = exportRoot;
// Authentication mode
var authOptions = new[]
{
"Anonymous - No authentication required",
"OAuth - OAuth 2.0 token validation",
"ApiKey - API key authentication",
"mTLS - Client certificate authentication"
};
var authChoice = context.NonInteractive
? 0
: context.PromptForSelection("Select authentication mode:", authOptions);
var authMode = authChoice switch
{
0 => "anonymous",
1 => "oauth",
2 => "apikey",
3 => "mtls",
_ => "anonymous"
};
config["sources.mirrorServer.authentication"] = authMode;
// OAuth configuration if selected
if (authMode == "oauth")
{
var issuer = GetOrPrompt(
context,
"sources.mirrorServer.oauth.issuer",
"OAuth issuer URL",
null);
if (!string.IsNullOrEmpty(issuer))
{
config["sources.mirrorServer.oauth.issuer"] = issuer;
var audience = GetOrPrompt(
context,
"sources.mirrorServer.oauth.audience",
"OAuth audience (optional)",
"");
if (!string.IsNullOrEmpty(audience))
{
config["sources.mirrorServer.oauth.audience"] = audience;
}
}
}
// Rate limiting
var enableRateLimiting = context.NonInteractive
? GetBoolOrDefault(context, "sources.mirrorServer.rateLimits.enabled", true)
: context.PromptForConfirmation("Enable rate limiting for mirror endpoints?", true);
if (enableRateLimiting)
{
config["sources.mirrorServer.rateLimits.enabled"] = "true";
config["sources.mirrorServer.rateLimits.forInstance.perSeconds"] = "60";
config["sources.mirrorServer.rateLimits.forInstance.maxRequests"] = "100";
}
return Task.FromResult(config);
}
public override async Task<SetupStepValidationResult> ValidateAsync(
SetupStepContext context,
CancellationToken ct = default)
{
var registry = _sourceRegistry;
if (registry is null)
{
return SetupStepValidationResult.Success("Source registry not available (using defaults)");
}
// Check that at least some sources are enabled
var enabledSources = await registry.GetEnabledSourcesAsync(ct);
if (enabledSources.Length == 0)
{
return SetupStepValidationResult.Failed(
"No sources are enabled",
errors: new[] { "At least one source must be enabled for vulnerability scanning" },
warnings: null);
}
// Verify at least one healthy source
var checkResult = await registry.CheckAllAndAutoConfigureAsync(ct);
if (checkResult.HealthyCount == 0)
{
return SetupStepValidationResult.Failed(
"No healthy sources available",
errors: new[] { "All enabled sources failed connectivity checks" },
warnings: new[] { "Run 'stella sources check' for details" });
}
return SetupStepValidationResult.Success(
$"Validated: {checkResult.HealthyCount} healthy source(s)");
}
}

View File

@@ -0,0 +1,224 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
/// <summary>
/// Setup step for OpenTelemetry configuration.
/// </summary>
public sealed class TelemetrySetupStep : SetupStepBase
{
private const string DefaultServiceName = "stellaops";
public TelemetrySetupStep()
: base(
id: "telemetry",
name: "OpenTelemetry",
description: "Configure OpenTelemetry for distributed tracing, metrics, and logging.",
category: SetupCategory.Observability,
order: 10,
isRequired: false,
validationChecks: new[] { "check.telemetry.otlp.connectivity" })
{
}
public override async Task<SetupStepResult> ExecuteAsync(
SetupStepContext context,
CancellationToken ct = default)
{
Output(context, "Configuring OpenTelemetry...");
try
{
// Get OTLP endpoint
var otlpEndpoint = GetOrPrompt(
context,
"telemetry.otlpEndpoint",
"OTLP endpoint (leave empty to skip)",
Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") ?? "");
if (string.IsNullOrEmpty(otlpEndpoint))
{
if (context.NonInteractive)
{
return SetupStepResult.Skipped("No OTLP endpoint provided");
}
if (!context.PromptForConfirmation("Skip telemetry configuration?", true))
{
otlpEndpoint = context.PromptForInput("OTLP endpoint", "http://localhost:4317");
}
}
if (string.IsNullOrEmpty(otlpEndpoint))
{
return SetupStepResult.Skipped("Telemetry configuration skipped");
}
// Get service name
var serviceName = GetOrPrompt(
context,
"telemetry.serviceName",
"Service name",
Environment.GetEnvironmentVariable("OTEL_SERVICE_NAME") ?? DefaultServiceName);
// Get feature toggles
var enableTracing = GetBoolOrDefault(context, "telemetry.enableTracing", true);
var enableMetrics = GetBoolOrDefault(context, "telemetry.enableMetrics", true);
var enableLogging = GetBoolOrDefault(context, "telemetry.enableLogging", true);
if (!context.NonInteractive)
{
Output(context, "Select telemetry features to enable:");
enableTracing = context.PromptForConfirmation("Enable distributed tracing?", enableTracing);
enableMetrics = context.PromptForConfirmation("Enable metrics export?", enableMetrics);
enableLogging = context.PromptForConfirmation("Enable log export?", enableLogging);
}
if (context.DryRun)
{
Output(context, $"[DRY RUN] Would configure OTLP endpoint: {otlpEndpoint}");
return SetupStepResult.Success(
"Telemetry configuration prepared (dry run)",
appliedConfig: new Dictionary<string, string>
{
["telemetry.otlpEndpoint"] = otlpEndpoint,
["telemetry.serviceName"] = serviceName,
["telemetry.enableTracing"] = enableTracing.ToString().ToLowerInvariant(),
["telemetry.enableMetrics"] = enableMetrics.ToString().ToLowerInvariant(),
["telemetry.enableLogging"] = enableLogging.ToString().ToLowerInvariant()
});
}
// Test OTLP endpoint connectivity
Output(context, $"Testing OTLP endpoint connectivity at {otlpEndpoint}...");
var reachable = await TestOtlpEndpointAsync(otlpEndpoint, ct);
if (!reachable)
{
OutputWarning(context, "OTLP endpoint is not reachable - telemetry may not be exported");
if (!context.NonInteractive &&
!context.PromptForConfirmation("Continue with unreachable endpoint?", true))
{
return SetupStepResult.Failed("OTLP endpoint not reachable", canRetry: true);
}
}
else
{
Output(context, "OTLP endpoint is reachable.");
}
var enabledFeatures = new List<string>();
if (enableTracing) enabledFeatures.Add("tracing");
if (enableMetrics) enabledFeatures.Add("metrics");
if (enableLogging) enabledFeatures.Add("logging");
Output(context, $"Enabled features: {string.Join(", ", enabledFeatures)}");
return SetupStepResult.Success(
$"Telemetry configured: {otlpEndpoint}",
appliedConfig: new Dictionary<string, string>
{
["telemetry.otlpEndpoint"] = otlpEndpoint,
["telemetry.serviceName"] = serviceName,
["telemetry.enableTracing"] = enableTracing.ToString().ToLowerInvariant(),
["telemetry.enableMetrics"] = enableMetrics.ToString().ToLowerInvariant(),
["telemetry.enableLogging"] = enableLogging.ToString().ToLowerInvariant()
});
}
catch (Exception ex)
{
OutputError(context, $"Telemetry setup failed: {ex.Message}");
return SetupStepResult.Failed(
$"Telemetry setup failed: {ex.Message}",
exception: ex,
canRetry: true);
}
}
public override async Task<SetupStepValidationResult> ValidateAsync(
SetupStepContext context,
CancellationToken ct = default)
{
if (!context.ConfigValues.TryGetValue("telemetry.otlpEndpoint", out var endpoint) ||
string.IsNullOrEmpty(endpoint))
{
// Telemetry is optional
return SetupStepValidationResult.Success("Telemetry not configured (optional)");
}
var warnings = new List<string>();
var reachable = await TestOtlpEndpointAsync(endpoint, ct);
if (!reachable)
{
warnings.Add($"OTLP endpoint {endpoint} is not reachable");
}
return new SetupStepValidationResult
{
Valid = true,
Message = reachable ? "Telemetry endpoint validated" : "Telemetry endpoint not reachable",
Warnings = warnings
};
}
private static async Task<bool> TestOtlpEndpointAsync(string endpoint, CancellationToken ct)
{
try
{
// Parse the endpoint to determine protocol
var uri = new Uri(endpoint);
if (uri.Scheme == "http" || uri.Scheme == "https")
{
// HTTP/gRPC endpoint - try to connect
using var client = new HttpClient();
client.Timeout = TimeSpan.FromSeconds(5);
// OTLP HTTP uses different paths for different signals
// Try the root or a health check path
try
{
var response = await client.GetAsync(endpoint, ct);
// Any response (even 404) means the endpoint is reachable
return true;
}
catch (HttpRequestException)
{
// Try gRPC reflection or just TCP connect
return await TryTcpConnectAsync(uri.Host, uri.Port, ct);
}
}
else
{
// Assume it's a gRPC endpoint
return await TryTcpConnectAsync(uri.Host, uri.Port, ct);
}
}
catch
{
return false;
}
}
private static async Task<bool> TryTcpConnectAsync(string host, int port, CancellationToken ct)
{
try
{
using var client = new System.Net.Sockets.TcpClient();
var connectTask = client.ConnectAsync(host, port, ct);
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5), ct);
var completedTask = await Task.WhenAny(connectTask.AsTask(), timeoutTask);
return completedTask == connectTask.AsTask() && client.Connected;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,301 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
/// <summary>
/// Setup step for creating the initial super user and optional additional users.
/// </summary>
public sealed class UsersSetupStep : SetupStepBase
{
private const int DefaultMinPasswordLength = 12;
public UsersSetupStep()
: base(
id: "users",
name: "User Management",
description: "Create the initial super user (administrator) and optional additional users.",
category: SetupCategory.Security,
order: 20,
isRequired: true,
dependencies: new[] { "authority" },
validationChecks: new[] { "check.users.superuser.exists", "check.authority.bootstrap.exists" })
{
}
public override async Task<SetupStepResult> ExecuteAsync(
SetupStepContext context,
CancellationToken ct = default)
{
Output(context, "Configuring initial users...");
try
{
var appliedConfig = new Dictionary<string, string>();
// Check if using LDAP - if so, skip user creation
var provider = GetOrDefault(context, "authority.provider", "standard");
if (provider == "ldap")
{
Output(context, "LDAP authentication is configured - users are managed externally.");
Output(context, "Ensure your LDAP directory has at least one user with administrator privileges.");
// Ask for admin group mapping
var adminGroup = GetOrPrompt(
context,
"authority.ldap.adminGroup",
"LDAP group for administrators (e.g., cn=admins,ou=groups,dc=example,dc=com)");
appliedConfig["Authority:Plugins:Ldap:AdminGroup"] = adminGroup;
return SetupStepResult.Success(
"LDAP admin group configured",
appliedConfig: appliedConfig);
}
// Standard authentication - create bootstrap user
Output(context, "Creating super user account...");
var username = GetOrPrompt(context, "users.superuser.username", "Super user username", "admin");
var email = GetOrPrompt(context, "users.superuser.email", "Super user email");
// Get password with validation
string password;
var minLength = GetIntOrDefault(context, "Authority:PasswordPolicy:MinLength", DefaultMinPasswordLength);
if (context.ConfigValues.TryGetValue("users.superuser.password", out var existingPassword) &&
!string.IsNullOrEmpty(existingPassword))
{
password = existingPassword;
}
else
{
while (true)
{
password = context.PromptForSecret("Super user password");
var validationResult = ValidatePassword(password, minLength);
if (validationResult.IsValid)
{
break;
}
OutputWarning(context, $"Password does not meet requirements: {string.Join(", ", validationResult.Errors)}");
}
}
appliedConfig["Authority:Bootstrap:Enabled"] = "true";
appliedConfig["Authority:Bootstrap:Username"] = username;
appliedConfig["Authority:Bootstrap:Email"] = email;
appliedConfig["Authority:Bootstrap:Password"] = password;
if (context.DryRun)
{
Output(context, "[DRY RUN] Would create super user:");
Output(context, $" - Username: {username}");
Output(context, $" - Email: {email}");
return SetupStepResult.Success(
"Super user prepared (dry run)",
appliedConfig: SanitizeConfig(appliedConfig));
}
Output(context, $"Super user '{username}' configured.");
// Ask about additional users
var createAdditional = context.PromptForConfirmation("Would you like to create additional users?", false);
var additionalUsers = new List<(string Username, string Email, string Role)>();
while (createAdditional)
{
var addUsername = GetOrPrompt(context, $"users.additional.{additionalUsers.Count}.username", "Username");
var addEmail = GetOrPrompt(context, $"users.additional.{additionalUsers.Count}.email", "Email");
var addRole = GetOrPromptChoice(
context,
$"users.additional.{additionalUsers.Count}.role",
"Role",
new[] { "user", "operator", "admin" },
"user");
additionalUsers.Add((addUsername, addEmail, addRole));
Output(context, $"User '{addUsername}' added with role '{addRole}'.");
createAdditional = context.PromptForConfirmation("Add another user?", false);
}
// Store additional users in config
for (int i = 0; i < additionalUsers.Count; i++)
{
var (addUsername, addEmail, addRole) = additionalUsers[i];
appliedConfig[$"Authority:Users:{i}:Username"] = addUsername;
appliedConfig[$"Authority:Users:{i}:Email"] = addEmail;
appliedConfig[$"Authority:Users:{i}:Role"] = addRole;
}
var outputValues = new Dictionary<string, string>
{
["users.superuser.created"] = "true",
["users.additional.count"] = additionalUsers.Count.ToString()
};
return SetupStepResult.Success(
$"Created super user '{username}' and {additionalUsers.Count} additional user(s)",
outputValues: outputValues,
appliedConfig: SanitizeConfig(appliedConfig));
}
catch (Exception ex)
{
OutputError(context, $"User setup failed: {ex.Message}");
return SetupStepResult.Failed(
$"User setup failed: {ex.Message}",
exception: ex,
canRetry: true);
}
}
public override Task<SetupStepPrerequisiteResult> CheckPrerequisitesAsync(
SetupStepContext context,
CancellationToken ct = default)
{
// Check if authority step was completed
var hasProvider = context.ConfigValues.ContainsKey("authority.provider") ||
context.ConfigValues.ContainsKey("Authority:Plugins:Standard:Enabled") ||
context.ConfigValues.ContainsKey("Authority:Plugins:Ldap:Enabled");
if (!hasProvider)
{
return Task.FromResult(SetupStepPrerequisiteResult.Failed(
"Authority provider must be configured before creating users",
missing: new[] { "authority" },
suggestions: new[] { "Run 'stella setup --step authority' first" }));
}
var isInteractive = !context.NonInteractive;
var hasSuperUser = context.ConfigValues.ContainsKey("users.superuser.username") &&
context.ConfigValues.ContainsKey("users.superuser.email") &&
context.ConfigValues.ContainsKey("users.superuser.password");
if (!hasSuperUser && !isInteractive)
{
// Check if it's LDAP - then we don't need local users
var provider = GetOrDefault(context, "authority.provider", null);
if (provider != "ldap")
{
return Task.FromResult(SetupStepPrerequisiteResult.Failed(
"Super user credentials required in non-interactive mode",
missing: new[] { "users.superuser.username", "users.superuser.email", "users.superuser.password" },
suggestions: new[] { "Provide super user details in config file" }));
}
}
return Task.FromResult(SetupStepPrerequisiteResult.Success());
}
public override Task<SetupStepValidationResult> ValidateAsync(
SetupStepContext context,
CancellationToken ct = default)
{
// Check if LDAP - then we rely on external user management
var provider = GetOrDefault(context, "authority.provider", "standard");
if (provider == "ldap")
{
var adminGroup = GetOrDefault(context, "Authority:Plugins:Ldap:AdminGroup", null);
if (string.IsNullOrEmpty(adminGroup))
{
return Task.FromResult(SetupStepValidationResult.Warning(
"LDAP admin group not configured",
warnings: new[] { "Consider setting Authority:Plugins:Ldap:AdminGroup for automatic admin mapping" }));
}
return Task.FromResult(SetupStepValidationResult.Success("LDAP user configuration validated"));
}
// Standard auth - check bootstrap user config
var hasBootstrap = context.ConfigValues.ContainsKey("Authority:Bootstrap:Username") &&
context.ConfigValues.ContainsKey("Authority:Bootstrap:Email");
if (!hasBootstrap)
{
return Task.FromResult(SetupStepValidationResult.Failed(
"Bootstrap user not configured",
errors: new[] { "Authority:Bootstrap:Username and Email must be set" }));
}
return Task.FromResult(SetupStepValidationResult.Success("User configuration validated"));
}
private static (bool IsValid, string[] Errors) ValidatePassword(string password, int minLength)
{
var errors = new List<string>();
if (string.IsNullOrEmpty(password))
{
errors.Add("Password cannot be empty");
return (false, errors.ToArray());
}
if (password.Length < minLength)
{
errors.Add($"Password must be at least {minLength} characters");
}
if (!Regex.IsMatch(password, "[A-Z]"))
{
errors.Add("Password must contain at least one uppercase letter");
}
if (!Regex.IsMatch(password, "[a-z]"))
{
errors.Add("Password must contain at least one lowercase letter");
}
if (!Regex.IsMatch(password, "[0-9]"))
{
errors.Add("Password must contain at least one digit");
}
if (!Regex.IsMatch(password, "[^a-zA-Z0-9]"))
{
errors.Add("Password must contain at least one special character");
}
return (errors.Count == 0, errors.ToArray());
}
private static Dictionary<string, string> SanitizeConfig(Dictionary<string, string> config)
{
var sanitized = new Dictionary<string, string>(config);
// Remove password from the returned config for security
foreach (var key in config.Keys)
{
if (key.Contains("Password", StringComparison.OrdinalIgnoreCase))
{
sanitized[key] = "***REDACTED***";
}
}
return sanitized;
}
private string GetOrPromptChoice(
SetupStepContext context,
string key,
string prompt,
string[] options,
string defaultValue)
{
if (context.ConfigValues.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
{
return value;
}
return context.PromptForChoice(prompt, options, defaultValue);
}
private new static string? GetOrDefault(SetupStepContext context, string key, string? defaultValue)
{
return context.ConfigValues.TryGetValue(key, out var value) ? value : defaultValue;
}
}

View File

@@ -0,0 +1,337 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cli.Commands.Setup.Steps.Implementations;
/// <summary>
/// Setup step for secrets vault configuration.
/// Supports HashiCorp Vault, Azure Key Vault, AWS Secrets Manager, and GCP Secret Manager.
/// </summary>
public sealed class VaultSetupStep : SetupStepBase
{
private static readonly string[] SupportedProviders = { "hashicorp", "azure", "aws", "gcp" };
public VaultSetupStep()
: base(
id: "vault",
name: "Secrets Vault",
description: "Configure a secrets vault for storing sensitive configuration (HashiCorp Vault, Azure Key Vault, AWS Secrets Manager, or GCP Secret Manager).",
category: SetupCategory.Security,
order: 10,
isRequired: false,
validationChecks: new[] { "check.integration.vault.connectivity", "check.integration.vault.auth" })
{
}
public override async Task<SetupStepResult> ExecuteAsync(
SetupStepContext context,
CancellationToken ct = default)
{
Output(context, "Configuring secrets vault...");
try
{
// Select provider
var provider = GetOrSelectProvider(context);
if (string.IsNullOrEmpty(provider))
{
return SetupStepResult.Skipped("No vault provider selected");
}
Output(context, $"Configuring {GetProviderDisplayName(provider)} vault...");
var result = provider.ToLowerInvariant() switch
{
"hashicorp" => await ConfigureHashiCorpVaultAsync(context, ct),
"azure" => await ConfigureAzureKeyVaultAsync(context, ct),
"aws" => await ConfigureAwsSecretsManagerAsync(context, ct),
"gcp" => await ConfigureGcpSecretManagerAsync(context, ct),
_ => SetupStepResult.Failed($"Unsupported vault provider: {provider}")
};
return result;
}
catch (Exception ex)
{
OutputError(context, $"Vault setup failed: {ex.Message}");
return SetupStepResult.Failed(
$"Vault setup failed: {ex.Message}",
exception: ex,
canRetry: true);
}
}
private async Task<SetupStepResult> ConfigureHashiCorpVaultAsync(
SetupStepContext context,
CancellationToken ct)
{
var address = GetOrPrompt(context, "vault.address", "Vault address", "http://localhost:8200");
var mountPath = GetOrPrompt(context, "vault.mountPath", "Secrets mount path", "secret");
var ns = context.ConfigValues.TryGetValue("vault.namespace", out var nsVal) ? nsVal : null;
// Get authentication
var token = context.ConfigValues.TryGetValue("vault.token", out var t) ? t : null;
var roleId = context.ConfigValues.TryGetValue("vault.roleId", out var r) ? r : null;
var secretId = context.ConfigValues.TryGetValue("vault.secretId", out var s) ? s : null;
if (string.IsNullOrEmpty(token) && (string.IsNullOrEmpty(roleId) || string.IsNullOrEmpty(secretId)))
{
if (!context.NonInteractive)
{
var authMethod = context.PromptForSelection(
"Select authentication method",
new[] { "Token", "AppRole" });
if (authMethod == 0)
{
token = context.PromptForSecret("Vault token");
}
else
{
roleId = context.PromptForInput("AppRole Role ID", null);
secretId = context.PromptForSecret("AppRole Secret ID");
}
}
else
{
return SetupStepResult.Failed(
"Vault authentication required",
canRetry: true);
}
}
if (context.DryRun)
{
Output(context, $"[DRY RUN] Would configure HashiCorp Vault at {address}");
return SetupStepResult.Success(
"HashiCorp Vault configuration prepared (dry run)",
appliedConfig: new Dictionary<string, string>
{
["vault.provider"] = "hashicorp",
["vault.address"] = address,
["vault.mountPath"] = mountPath
});
}
// Test connection
Output(context, $"Testing connection to {address}...");
var health = await TestHashiCorpVaultAsync(address, token, ns, ct);
if (!health.Initialized)
{
OutputWarning(context, "Vault is not initialized");
}
if (health.Sealed)
{
return SetupStepResult.Failed("Vault is sealed. Please unseal the vault first.");
}
Output(context, "HashiCorp Vault connection successful.");
OutputVerbose(context, $"Vault version: {health.Version}");
var appliedConfig = new Dictionary<string, string>
{
["vault.provider"] = "hashicorp",
["vault.address"] = address,
["vault.mountPath"] = mountPath
};
if (!string.IsNullOrEmpty(ns))
{
appliedConfig["vault.namespace"] = ns;
}
return SetupStepResult.Success(
$"HashiCorp Vault configured at {address}",
outputValues: new Dictionary<string, string> { ["vault.version"] = health.Version },
appliedConfig: appliedConfig);
}
private Task<SetupStepResult> ConfigureAzureKeyVaultAsync(
SetupStepContext context,
CancellationToken ct)
{
var vaultUrl = GetOrPrompt(context, "vault.address", "Key Vault URL", null);
if (string.IsNullOrEmpty(vaultUrl))
{
return Task.FromResult(SetupStepResult.Failed("Azure Key Vault URL is required"));
}
if (context.DryRun)
{
Output(context, $"[DRY RUN] Would configure Azure Key Vault at {vaultUrl}");
return Task.FromResult(SetupStepResult.Success(
"Azure Key Vault configuration prepared (dry run)",
appliedConfig: new Dictionary<string, string>
{
["vault.provider"] = "azure",
["vault.address"] = vaultUrl
}));
}
// Azure Key Vault uses DefaultAzureCredential in production
Output(context, "Azure Key Vault will use DefaultAzureCredential for authentication.");
Output(context, "Ensure the identity running StellaOps has Key Vault access.");
return Task.FromResult(SetupStepResult.Success(
$"Azure Key Vault configured: {vaultUrl}",
appliedConfig: new Dictionary<string, string>
{
["vault.provider"] = "azure",
["vault.address"] = vaultUrl
}));
}
private Task<SetupStepResult> ConfigureAwsSecretsManagerAsync(
SetupStepContext context,
CancellationToken ct)
{
var region = GetOrPrompt(context, "vault.region", "AWS Region", "us-east-1");
var prefix = GetOrPrompt(context, "vault.prefix", "Secrets prefix", "stellaops/");
if (context.DryRun)
{
Output(context, $"[DRY RUN] Would configure AWS Secrets Manager in {region}");
return Task.FromResult(SetupStepResult.Success(
"AWS Secrets Manager configuration prepared (dry run)",
appliedConfig: new Dictionary<string, string>
{
["vault.provider"] = "aws",
["vault.region"] = region,
["vault.prefix"] = prefix
}));
}
Output(context, "AWS Secrets Manager will use default credentials chain.");
Output(context, "Ensure the IAM role/user has SecretsManager access.");
return Task.FromResult(SetupStepResult.Success(
$"AWS Secrets Manager configured in {region}",
appliedConfig: new Dictionary<string, string>
{
["vault.provider"] = "aws",
["vault.region"] = region,
["vault.prefix"] = prefix
}));
}
private Task<SetupStepResult> ConfigureGcpSecretManagerAsync(
SetupStepContext context,
CancellationToken ct)
{
var project = GetOrPrompt(context, "vault.project", "GCP Project ID", null);
if (string.IsNullOrEmpty(project))
{
return Task.FromResult(SetupStepResult.Failed("GCP Project ID is required"));
}
if (context.DryRun)
{
Output(context, $"[DRY RUN] Would configure GCP Secret Manager for project {project}");
return Task.FromResult(SetupStepResult.Success(
"GCP Secret Manager configuration prepared (dry run)",
appliedConfig: new Dictionary<string, string>
{
["vault.provider"] = "gcp",
["vault.project"] = project
}));
}
Output(context, "GCP Secret Manager will use application default credentials.");
Output(context, "Ensure the service account has Secret Manager access.");
return Task.FromResult(SetupStepResult.Success(
$"GCP Secret Manager configured for project {project}",
appliedConfig: new Dictionary<string, string>
{
["vault.provider"] = "gcp",
["vault.project"] = project
}));
}
private string? GetOrSelectProvider(SetupStepContext context)
{
if (context.ConfigValues.TryGetValue("vault.provider", out var provider))
{
return provider;
}
if (context.NonInteractive)
{
return null;
}
var options = new List<string>
{
"HashiCorp Vault",
"Azure Key Vault",
"AWS Secrets Manager",
"GCP Secret Manager",
"Skip (no vault)"
};
var selection = context.PromptForSelection("Select secrets vault provider", options);
return selection switch
{
0 => "hashicorp",
1 => "azure",
2 => "aws",
3 => "gcp",
_ => null
};
}
private static string GetProviderDisplayName(string provider)
{
return provider.ToLowerInvariant() switch
{
"hashicorp" => "HashiCorp Vault",
"azure" => "Azure Key Vault",
"aws" => "AWS Secrets Manager",
"gcp" => "GCP Secret Manager",
_ => provider
};
}
private static async Task<VaultHealthResponse> TestHashiCorpVaultAsync(
string address,
string? token,
string? ns,
CancellationToken ct)
{
using var client = new HttpClient();
client.BaseAddress = new Uri(address.TrimEnd('/'));
if (!string.IsNullOrEmpty(token))
{
client.DefaultRequestHeaders.Add("X-Vault-Token", token);
}
if (!string.IsNullOrEmpty(ns))
{
client.DefaultRequestHeaders.Add("X-Vault-Namespace", ns);
}
var response = await client.GetAsync("/v1/sys/health?standbyok=true&uninitcode=200&sealedcode=200", ct);
var json = await response.Content.ReadAsStringAsync(ct);
var health = JsonSerializer.Deserialize<VaultHealthResponse>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return health ?? new VaultHealthResponse { Initialized = false, Sealed = true, Version = "Unknown" };
}
private sealed class VaultHealthResponse
{
public bool Initialized { get; set; }
public bool Sealed { get; set; }
public string Version { get; set; } = "Unknown";
public string ClusterName { get; set; } = "";
}
}

View File

@@ -33,5 +33,10 @@ public enum SetupCategory
/// <summary>
/// Optional features and enhancements.
/// </summary>
Optional = 5
Optional = 5,
/// <summary>
/// Data sources (advisory feeds, CVE databases).
/// </summary>
Data = 6
}

View File

@@ -83,4 +83,26 @@ public sealed class SetupStepContext
/// Function to output an error to the console.
/// </summary>
public Action<string> OutputError { get; init; } = msg => Console.Error.WriteLine($"ERROR: {msg}");
/// <summary>
/// Prompts user to select from a list of options and returns the selected value.
/// </summary>
public string PromptForChoice(string prompt, string[] options, string defaultValue)
{
if (NonInteractive)
{
return defaultValue;
}
var index = PromptForSelection(prompt, options);
return index >= 0 && index < options.Length ? options[index] : defaultValue;
}
/// <summary>
/// Prompts user for confirmation with a default of true.
/// </summary>
public bool Confirm(string prompt)
{
return PromptForConfirmation(prompt, true);
}
}

View File

@@ -142,6 +142,16 @@ public sealed record SetupStepResult
/// </summary>
public bool CanRollback { get; init; }
/// <summary>
/// Whether the step completed successfully.
/// </summary>
public bool IsSuccess => Status == SetupStepStatus.Completed;
/// <summary>
/// Whether the step failed.
/// </summary>
public bool IsFailure => Status == SetupStepStatus.Failed;
public static SetupStepResult Success(
string? message = null,
IReadOnlyDictionary<string, string>? outputValues = null,
@@ -215,6 +225,16 @@ public sealed record SetupStepValidationResult
Errors = errors ?? Array.Empty<string>(),
Warnings = warnings ?? Array.Empty<string>()
};
public static SetupStepValidationResult Warning(
string message,
IReadOnlyList<string>? warnings = null) =>
new()
{
Valid = true,
Message = message,
Warnings = warnings ?? Array.Empty<string>()
};
}
/// <summary>

View File

@@ -0,0 +1,267 @@
// -----------------------------------------------------------------------------
// SourcesCommandGroup.cs
// Sprint: SPRINT_20260114_SOURCES_SETUP
// Task: CLI commands for advisory sources management
// Description: CLI commands for listing, checking, enabling, and disabling advisory sources.
// -----------------------------------------------------------------------------
using System.CommandLine;
using StellaOps.Cli.Extensions;
using StellaOps.Concelier.Core.Sources;
namespace StellaOps.Cli.Commands.Sources;
/// <summary>
/// CLI commands for advisory sources management.
/// Provides list, check, enable, and disable operations for CVE/advisory data sources.
/// </summary>
internal static class SourcesCommandGroup
{
/// <summary>
/// Adds sources management subcommands to an existing sources command.
/// </summary>
internal static void AddSourcesManagementCommands(
Command sourcesCommand,
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
sourcesCommand.Add(BuildListCommand(services, verboseOption, cancellationToken));
sourcesCommand.Add(BuildCheckCommand(services, verboseOption, cancellationToken));
sourcesCommand.Add(BuildEnableCommand(services, verboseOption, cancellationToken));
sourcesCommand.Add(BuildDisableCommand(services, verboseOption, cancellationToken));
sourcesCommand.Add(BuildStatusCommand(services, verboseOption, cancellationToken));
}
private static Command BuildListCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var categoryOption = new Option<string?>("--category", "-c")
{
Description = "Filter by source category (primary, distro, ecosystem, scoring, other)."
};
var enabledOnlyOption = new Option<bool>("--enabled-only")
{
Description = "Show only enabled sources."
};
var jsonOption = new Option<bool>("--json")
{
Description = "Output as JSON."
};
var command = new Command("list", "List all available advisory sources.")
{
categoryOption,
enabledOnlyOption,
jsonOption,
verboseOption
};
command.SetAction(parseResult =>
{
var category = parseResult.GetValue(categoryOption);
var enabledOnly = parseResult.GetValue(enabledOnlyOption);
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return SourcesCommandHandlers.HandleSourcesListAsync(
services,
category,
enabledOnly,
json,
verbose,
cancellationToken);
});
return command;
}
private static Command BuildCheckCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var sourceArgument = new Argument<string?>("source")
{
Description = "Specific source to check (omit to check all enabled sources).",
Arity = ArgumentArity.ZeroOrOne
};
var allOption = new Option<bool>("--all", "-a")
{
Description = "Check all sources, not just enabled ones."
};
var parallelOption = new Option<int>("--parallel", "-p")
{
Description = "Maximum number of parallel connectivity checks."
};
parallelOption.SetDefaultValue(10);
var timeoutOption = new Option<int>("--timeout", "-t")
{
Description = "Timeout in seconds for each connectivity check."
};
timeoutOption.SetDefaultValue(30);
var jsonOption = new Option<bool>("--json")
{
Description = "Output as JSON."
};
var autoDisableOption = new Option<bool>("--auto-disable")
{
Description = "Automatically disable sources that fail connectivity checks."
};
var command = new Command("check", "Check connectivity to advisory sources.")
{
sourceArgument,
allOption,
parallelOption,
timeoutOption,
jsonOption,
autoDisableOption,
verboseOption
};
command.SetAction(parseResult =>
{
var source = parseResult.GetValue(sourceArgument);
var all = parseResult.GetValue(allOption);
var parallel = parseResult.GetValue(parallelOption);
var timeout = parseResult.GetValue(timeoutOption);
var json = parseResult.GetValue(jsonOption);
var autoDisable = parseResult.GetValue(autoDisableOption);
var verbose = parseResult.GetValue(verboseOption);
return SourcesCommandHandlers.HandleSourcesCheckAsync(
services,
source,
all,
parallel,
timeout,
json,
autoDisable,
verbose,
cancellationToken);
});
return command;
}
private static Command BuildEnableCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var sourceArgument = new Argument<string[]>("sources")
{
Description = "Source(s) to enable.",
Arity = ArgumentArity.OneOrMore
};
var jsonOption = new Option<bool>("--json")
{
Description = "Output as JSON."
};
var command = new Command("enable", "Enable one or more advisory sources.")
{
sourceArgument,
jsonOption,
verboseOption
};
command.SetAction(parseResult =>
{
var sources = parseResult.GetValue(sourceArgument) ?? Array.Empty<string>();
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return SourcesCommandHandlers.HandleSourcesEnableAsync(
services,
sources,
json,
verbose,
cancellationToken);
});
return command;
}
private static Command BuildDisableCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var sourceArgument = new Argument<string[]>("sources")
{
Description = "Source(s) to disable.",
Arity = ArgumentArity.OneOrMore
};
var jsonOption = new Option<bool>("--json")
{
Description = "Output as JSON."
};
var command = new Command("disable", "Disable one or more advisory sources.")
{
sourceArgument,
jsonOption,
verboseOption
};
command.SetAction(parseResult =>
{
var sources = parseResult.GetValue(sourceArgument) ?? Array.Empty<string>();
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return SourcesCommandHandlers.HandleSourcesDisableAsync(
services,
sources,
json,
verbose,
cancellationToken);
});
return command;
}
private static Command BuildStatusCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var jsonOption = new Option<bool>("--json")
{
Description = "Output as JSON."
};
var command = new Command("status", "Show current source configuration status.")
{
jsonOption,
verboseOption
};
command.SetAction(parseResult =>
{
var json = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return SourcesCommandHandlers.HandleSourcesStatusAsync(
services,
json,
verbose,
cancellationToken);
});
return command;
}
}

View File

@@ -0,0 +1,376 @@
// -----------------------------------------------------------------------------
// SourcesCommandHandlers.cs
// Sprint: SPRINT_20260114_SOURCES_SETUP
// Task: CLI command handlers for advisory sources management
// Description: Handlers for sources list, check, enable, disable, and status commands.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.Core.Sources;
namespace StellaOps.Cli.Commands.Sources;
/// <summary>
/// Command handlers for advisory sources management.
/// </summary>
internal static class SourcesCommandHandlers
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public static async Task<int> HandleSourcesListAsync(
IServiceProvider services,
string? category,
bool enabledOnly,
bool json,
bool verbose,
CancellationToken ct)
{
var registry = services.GetRequiredService<ISourceRegistry>();
var sources = registry.GetAllSources();
// Filter by category if specified
if (!string.IsNullOrEmpty(category) &&
Enum.TryParse<SourceCategory>(category, ignoreCase: true, out var categoryEnum))
{
sources = registry.GetSourcesByCategory(categoryEnum);
}
// Filter by enabled status if requested
if (enabledOnly)
{
var enabledIds = await registry.GetEnabledSourcesAsync(ct);
var enabledSet = enabledIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
sources = sources.Where(s => enabledSet.Contains(s.Id)).ToImmutableArray();
}
if (json)
{
var output = new
{
totalCount = sources.Count,
sources = sources.Select(s => new
{
id = s.Id,
displayName = s.DisplayName,
category = s.Category.ToString().ToLowerInvariant(),
description = s.Description,
baseUrl = s.BaseEndpoint,
enabled = registry.IsEnabled(s.Id),
requiresAuth = s.RequiresAuthentication
}).ToArray()
};
Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions));
return 0;
}
// Table output
Console.WriteLine();
Console.WriteLine($"Advisory Sources ({sources.Count} total)");
Console.WriteLine(new string('=', 80));
Console.WriteLine();
var grouped = sources.GroupBy(s => s.Category).OrderBy(g => g.Key);
foreach (var group in grouped)
{
Console.WriteLine($"[{group.Key}]");
foreach (var source in group.OrderBy(s => s.Id))
{
var enabled = registry.IsEnabled(source.Id);
var status = enabled ? "[+]" : "[-]";
var authMark = source.RequiresAuthentication ? " (auth)" : "";
Console.WriteLine($" {status} {source.Id,-15} {source.DisplayName}{authMark}");
if (verbose)
{
Console.WriteLine($" {source.Description}");
}
}
Console.WriteLine();
}
return 0;
}
public static async Task<int> HandleSourcesCheckAsync(
IServiceProvider services,
string? source,
bool all,
int parallel,
int timeout,
bool json,
bool autoDisable,
bool verbose,
CancellationToken ct)
{
var registry = services.GetRequiredService<ISourceRegistry>();
ImmutableArray<SourceConnectivityResult> results;
if (!string.IsNullOrEmpty(source))
{
// Check a single source
var result = await registry.CheckConnectivityAsync(source, ct);
results = ImmutableArray.Create(result);
}
else if (all)
{
// Check all sources
var checkResult = await registry.CheckAllAndAutoConfigureAsync(ct);
results = checkResult.Results;
}
else
{
// Check only enabled sources
var enabledSources = await registry.GetEnabledSourcesAsync(ct);
results = await registry.CheckMultipleAsync(enabledSources, ct);
}
// Auto-disable failed sources if requested
var disabledSources = new List<string>();
if (autoDisable)
{
foreach (var result in results.Where(r => !r.IsHealthy))
{
if (await registry.DisableSourceAsync(result.SourceId, ct))
{
disabledSources.Add(result.SourceId);
}
}
}
if (json)
{
var output = new
{
checkedAt = DateTimeOffset.UtcNow.ToString("O"),
totalChecked = results.Length,
healthy = results.Count(r => r.IsHealthy),
failed = results.Count(r => !r.IsHealthy),
autoDisabled = disabledSources,
results = results.Select(r => new
{
sourceId = r.SourceId,
status = r.Status.ToString().ToLowerInvariant(),
isHealthy = r.IsHealthy,
latencyMs = r.Latency?.TotalMilliseconds,
errorCode = r.ErrorCode,
errorMessage = r.ErrorMessage,
httpStatusCode = r.HttpStatusCode,
possibleReasons = r.PossibleReasons.ToArray(),
remediationSteps = r.RemediationSteps.Select(s => new
{
order = s.Order,
description = s.Description,
command = s.Command
}).ToArray()
}).ToArray()
};
Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions));
return results.All(r => r.IsHealthy) ? 0 : 1;
}
// Console output
Console.WriteLine();
Console.WriteLine("Checking source connectivity...");
Console.WriteLine();
var healthyCount = 0;
var failedCount = 0;
foreach (var result in results.OrderBy(r => r.SourceId))
{
if (result.IsHealthy)
{
healthyCount++;
var latency = result.Latency.HasValue
? $" ({result.Latency.Value.TotalMilliseconds:F0}ms)"
: "";
Console.WriteLine($"[OK] {result.SourceId}{latency}");
}
else
{
failedCount++;
Console.WriteLine($"[FAIL] {result.SourceId}");
Console.WriteLine($" Error: {result.ErrorMessage}");
if (verbose && result.PossibleReasons.Length > 0)
{
Console.WriteLine();
Console.WriteLine(" Why:");
foreach (var reason in result.PossibleReasons)
{
Console.WriteLine($" - {reason}");
}
}
if (verbose && result.RemediationSteps.Length > 0)
{
Console.WriteLine();
Console.WriteLine(" How to fix:");
foreach (var step in result.RemediationSteps)
{
Console.WriteLine($" {step.Order}. {step.Description}");
if (!string.IsNullOrEmpty(step.Command))
{
Console.WriteLine($" $ {step.Command}");
}
}
}
Console.WriteLine();
}
}
Console.WriteLine();
Console.WriteLine($"Summary: {healthyCount}/{results.Length} sources healthy, {failedCount} failed");
if (disabledSources.Count > 0)
{
Console.WriteLine();
Console.WriteLine($"Auto-disabled {disabledSources.Count} source(s): {string.Join(", ", disabledSources)}");
}
return failedCount > 0 ? 1 : 0;
}
public static async Task<int> HandleSourcesEnableAsync(
IServiceProvider services,
string[] sources,
bool json,
bool verbose,
CancellationToken ct)
{
var registry = services.GetRequiredService<ISourceRegistry>();
var results = new List<(string SourceId, bool Success)>();
foreach (var sourceId in sources)
{
var success = await registry.EnableSourceAsync(sourceId, ct);
results.Add((sourceId, success));
}
if (json)
{
var output = new
{
enabled = results.Where(r => r.Success).Select(r => r.SourceId).ToArray(),
failed = results.Where(r => !r.Success).Select(r => r.SourceId).ToArray()
};
Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions));
return results.All(r => r.Success) ? 0 : 1;
}
foreach (var (sourceId, success) in results)
{
if (success)
{
Console.WriteLine($"[OK] Enabled source: {sourceId}");
}
else
{
Console.WriteLine($"[FAIL] Failed to enable source: {sourceId} (not found)");
}
}
return results.All(r => r.Success) ? 0 : 1;
}
public static async Task<int> HandleSourcesDisableAsync(
IServiceProvider services,
string[] sources,
bool json,
bool verbose,
CancellationToken ct)
{
var registry = services.GetRequiredService<ISourceRegistry>();
var results = new List<(string SourceId, bool Success)>();
foreach (var sourceId in sources)
{
var success = await registry.DisableSourceAsync(sourceId, ct);
results.Add((sourceId, success));
}
if (json)
{
var output = new
{
disabled = results.Where(r => r.Success).Select(r => r.SourceId).ToArray(),
failed = results.Where(r => !r.Success).Select(r => r.SourceId).ToArray()
};
Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions));
return results.All(r => r.Success) ? 0 : 1;
}
foreach (var (sourceId, success) in results)
{
if (success)
{
Console.WriteLine($"[OK] Disabled source: {sourceId}");
}
else
{
Console.WriteLine($"[FAIL] Failed to disable source: {sourceId} (not found)");
}
}
return results.All(r => r.Success) ? 0 : 1;
}
public static async Task<int> HandleSourcesStatusAsync(
IServiceProvider services,
bool json,
bool verbose,
CancellationToken ct)
{
var registry = services.GetRequiredService<ISourceRegistry>();
var allSources = registry.GetAllSources();
var enabledSources = await registry.GetEnabledSourcesAsync(ct);
var enabledSet = enabledSources.ToHashSet(StringComparer.OrdinalIgnoreCase);
var byCategory = allSources.GroupBy(s => s.Category)
.Select(g => new
{
Category = g.Key.ToString().ToLowerInvariant(),
Total = g.Count(),
Enabled = g.Count(s => enabledSet.Contains(s.Id))
})
.ToArray();
if (json)
{
var output = new
{
totalSources = allSources.Count,
enabledSources = enabledSources.Length,
disabledSources = allSources.Count - enabledSources.Length,
byCategory = byCategory.ToDictionary(c => c.Category, c => new { c.Total, c.Enabled })
};
Console.WriteLine(JsonSerializer.Serialize(output, JsonOptions));
return 0;
}
Console.WriteLine();
Console.WriteLine("Advisory Sources Status");
Console.WriteLine(new string('=', 40));
Console.WriteLine();
Console.WriteLine($"Total Sources: {allSources.Count}");
Console.WriteLine($"Enabled Sources: {enabledSources.Length}");
Console.WriteLine($"Disabled Sources: {allSources.Count - enabledSources.Length}");
Console.WriteLine();
Console.WriteLine("By Category:");
foreach (var cat in byCategory.OrderBy(c => c.Category))
{
Console.WriteLine($" {cat.Category,-12} {cat.Enabled}/{cat.Total} enabled");
}
Console.WriteLine();
return 0;
}
}

View File

@@ -41,6 +41,11 @@ public sealed class StellaOpsCliOptions
/// </summary>
public StellaOpsCliPolicyGatewayOptions? PolicyGateway { get; set; }
/// <summary>
/// AdvisoryAI configuration for chat and advisory commands.
/// </summary>
public StellaOpsCliAdvisoryAiOptions AdvisoryAi { get; set; } = new();
/// <summary>
/// Indicates if CLI is running in offline mode.
/// </summary>
@@ -130,3 +135,96 @@ public sealed class StellaOpsCliPolicyGatewayOptions
/// </summary>
public int TimeoutSeconds { get; set; } = 30;
}
/// <summary>
/// Configuration options for AdvisoryAI chat and advisory commands.
/// </summary>
public sealed class StellaOpsCliAdvisoryAiOptions
{
/// <summary>
/// Whether AdvisoryAI is enabled.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Default LLM provider (openai, claude, gemini, ollama).
/// </summary>
public string? DefaultProvider { get; set; }
/// <summary>
/// OpenAI provider configuration.
/// </summary>
public StellaOpsCliLlmProviderOptions? OpenAi { get; set; }
/// <summary>
/// Claude (Anthropic) provider configuration.
/// </summary>
public StellaOpsCliLlmProviderOptions? Claude { get; set; }
/// <summary>
/// Gemini (Google) provider configuration.
/// </summary>
public StellaOpsCliLlmProviderOptions? Gemini { get; set; }
/// <summary>
/// Ollama (local) provider configuration.
/// </summary>
public StellaOpsCliOllamaOptions? Ollama { get; set; }
/// <summary>
/// Check if any LLM provider is configured.
/// </summary>
public bool HasConfiguredProvider()
{
if (!Enabled)
{
return false;
}
return !string.IsNullOrEmpty(OpenAi?.ApiKey) ||
!string.IsNullOrEmpty(Claude?.ApiKey) ||
!string.IsNullOrEmpty(Gemini?.ApiKey) ||
Ollama?.Enabled == true ||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) ||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")) ||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GEMINI_API_KEY")) ||
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GOOGLE_API_KEY"));
}
}
/// <summary>
/// Configuration options for an LLM provider (API key-based).
/// </summary>
public sealed class StellaOpsCliLlmProviderOptions
{
/// <summary>
/// API key for the LLM provider.
/// </summary>
public string? ApiKey { get; set; }
/// <summary>
/// Model name to use.
/// </summary>
public string? Model { get; set; }
}
/// <summary>
/// Configuration options for Ollama (local LLM).
/// </summary>
public sealed class StellaOpsCliOllamaOptions
{
/// <summary>
/// Whether Ollama is enabled.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Ollama endpoint URL.
/// </summary>
public string Endpoint { get; set; } = "http://localhost:11434";
/// <summary>
/// Model name to use.
/// </summary>
public string Model { get; set; } = "llama3:8b";
}

View File

@@ -0,0 +1,235 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models.Chat;
namespace StellaOps.Cli.Services.Chat;
/// <summary>
/// HTTP client for AdvisoryAI chat operations.
/// </summary>
internal sealed class ChatClient : IChatClient
{
private readonly HttpClient _httpClient;
private readonly StellaOpsCliOptions _options;
private readonly JsonSerializerOptions _jsonOptions;
public ChatClient(HttpClient httpClient, StellaOpsCliOptions options)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
}
public async Task<ChatQueryResponse> QueryAsync(
ChatQueryRequest request,
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var url = BuildUrl("/api/v1/chat/query");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url);
AddHeaders(httpRequest, tenantId, userId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
var result = await response.Content.ReadFromJsonAsync<ChatQueryResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Chat query returned null response.");
}
public async Task<ChatDoctorResponse> GetDoctorAsync(
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default)
{
var url = BuildUrl("/api/v1/chat/doctor");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
AddHeaders(httpRequest, tenantId, userId);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
var result = await response.Content.ReadFromJsonAsync<ChatDoctorResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Chat doctor returned null response.");
}
public async Task<ChatSettingsResponse> GetSettingsAsync(
string scope = "effective",
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default)
{
var url = BuildUrl($"/api/v1/chat/settings?scope={Uri.EscapeDataString(scope)}");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
AddHeaders(httpRequest, tenantId, userId);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
var result = await response.Content.ReadFromJsonAsync<ChatSettingsResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Chat settings returned null response.");
}
public async Task<ChatSettingsResponse> UpdateSettingsAsync(
ChatSettingsUpdateRequest request,
string scope = "user",
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var url = BuildUrl($"/api/v1/chat/settings?scope={Uri.EscapeDataString(scope)}");
using var httpRequest = new HttpRequestMessage(HttpMethod.Put, url);
AddHeaders(httpRequest, tenantId, userId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
var result = await response.Content.ReadFromJsonAsync<ChatSettingsResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Chat settings update returned null response.");
}
public async Task ClearSettingsAsync(
string scope = "user",
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default)
{
var url = BuildUrl($"/api/v1/chat/settings?scope={Uri.EscapeDataString(scope)}");
using var httpRequest = new HttpRequestMessage(HttpMethod.Delete, url);
AddHeaders(httpRequest, tenantId, userId);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
}
private string BuildUrl(string path)
{
var baseUrl = _options.BackendUrl?.TrimEnd('/') ?? "http://localhost:5000";
return $"{baseUrl}{path}";
}
private static void AddHeaders(HttpRequestMessage request, string? tenantId, string? userId)
{
if (!string.IsNullOrEmpty(tenantId))
{
request.Headers.TryAddWithoutValidation("X-Tenant-Id", tenantId);
}
if (!string.IsNullOrEmpty(userId))
{
request.Headers.TryAddWithoutValidation("X-User-Id", userId);
}
request.Headers.TryAddWithoutValidation("X-Correlation-Id", Guid.NewGuid().ToString("N"));
}
private async Task EnsureSuccessOrThrowAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
{
return;
}
ChatErrorResponse? errorResponse = null;
try
{
errorResponse = await response.Content.ReadFromJsonAsync<ChatErrorResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
}
catch
{
// Ignore JSON parse errors for error response
}
var statusCode = (int)response.StatusCode;
var errorMessage = errorResponse?.Error ?? response.ReasonPhrase ?? "Unknown error";
var errorCode = errorResponse?.Code;
var exception = response.StatusCode switch
{
HttpStatusCode.BadRequest when errorCode == "GUARDRAIL_BLOCKED" =>
new ChatGuardrailException(errorMessage, errorResponse),
HttpStatusCode.Forbidden when errorCode == "TOOL_DENIED" =>
new ChatToolDeniedException(errorMessage, errorResponse),
HttpStatusCode.TooManyRequests =>
new ChatQuotaExceededException(errorMessage, errorResponse),
HttpStatusCode.ServiceUnavailable =>
new ChatServiceUnavailableException(errorMessage, errorResponse),
_ => new ChatException($"Chat API error ({statusCode}): {errorMessage}", errorResponse)
};
throw exception;
}
}
/// <summary>
/// Base exception for chat API errors.
/// </summary>
internal class ChatException : Exception
{
public ChatErrorResponse? ErrorResponse { get; }
public ChatException(string message, ChatErrorResponse? errorResponse = null)
: base(message)
{
ErrorResponse = errorResponse;
}
}
/// <summary>
/// Exception thrown when a guardrail blocks the request.
/// </summary>
internal sealed class ChatGuardrailException : ChatException
{
public ChatGuardrailException(string message, ChatErrorResponse? errorResponse = null)
: base(message, errorResponse)
{
}
}
/// <summary>
/// Exception thrown when tool access is denied.
/// </summary>
internal sealed class ChatToolDeniedException : ChatException
{
public ChatToolDeniedException(string message, ChatErrorResponse? errorResponse = null)
: base(message, errorResponse)
{
}
}
/// <summary>
/// Exception thrown when quota is exceeded.
/// </summary>
internal sealed class ChatQuotaExceededException : ChatException
{
public ChatQuotaExceededException(string message, ChatErrorResponse? errorResponse = null)
: base(message, errorResponse)
{
}
}
/// <summary>
/// Exception thrown when chat service is unavailable.
/// </summary>
internal sealed class ChatServiceUnavailableException : ChatException
{
public ChatServiceUnavailableException(string message, ChatErrorResponse? errorResponse = null)
: base(message, errorResponse)
{
}
}

View File

@@ -0,0 +1,59 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models.Chat;
namespace StellaOps.Cli.Services.Chat;
/// <summary>
/// Client interface for AdvisoryAI chat operations.
/// </summary>
internal interface IChatClient
{
/// <summary>
/// Send a chat query and receive a response.
/// </summary>
Task<ChatQueryResponse> QueryAsync(
ChatQueryRequest request,
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Get chat doctor diagnostics (quota status, tool access, last denial).
/// </summary>
Task<ChatDoctorResponse> GetDoctorAsync(
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Get current chat settings.
/// </summary>
Task<ChatSettingsResponse> GetSettingsAsync(
string scope = "effective",
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Update chat settings.
/// </summary>
Task<ChatSettingsResponse> UpdateSettingsAsync(
ChatSettingsUpdateRequest request,
string scope = "user",
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Clear chat settings overrides.
/// </summary>
Task ClearSettingsAsync(
string scope = "user",
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,429 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models.Chat;
/// <summary>
/// Output format for chat commands.
/// </summary>
internal enum ChatOutputFormat
{
Table,
Json,
Markdown
}
/// <summary>
/// Chat query request sent to the AdvisoryAI chat API.
/// </summary>
internal sealed record ChatQueryRequest
{
[JsonPropertyName("query")]
public required string Query { get; init; }
[JsonPropertyName("artifactDigest")]
public string? ArtifactDigest { get; init; }
[JsonPropertyName("imageReference")]
public string? ImageReference { get; init; }
[JsonPropertyName("environment")]
public string? Environment { get; init; }
[JsonPropertyName("conversationId")]
public string? ConversationId { get; init; }
[JsonPropertyName("userRoles")]
public List<string>? UserRoles { get; init; }
[JsonPropertyName("noAction")]
public bool NoAction { get; init; } = true;
[JsonPropertyName("includeEvidence")]
public bool IncludeEvidence { get; init; }
}
/// <summary>
/// Chat query response from the AdvisoryAI chat API.
/// </summary>
internal sealed record ChatQueryResponse
{
[JsonPropertyName("responseId")]
public required string ResponseId { get; init; }
[JsonPropertyName("bundleId")]
public string? BundleId { get; init; }
[JsonPropertyName("intent")]
public required string Intent { get; init; }
[JsonPropertyName("generatedAt")]
public required DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("summary")]
public required string Summary { get; init; }
[JsonPropertyName("impact")]
public ChatImpactAssessment? Impact { get; init; }
[JsonPropertyName("reachability")]
public ChatReachabilityAssessment? Reachability { get; init; }
[JsonPropertyName("mitigations")]
public List<ChatMitigationOption> Mitigations { get; init; } = [];
[JsonPropertyName("evidenceLinks")]
public List<ChatEvidenceLink> EvidenceLinks { get; init; } = [];
[JsonPropertyName("confidence")]
public required ChatConfidence Confidence { get; init; }
[JsonPropertyName("proposedActions")]
public List<ChatProposedAction> ProposedActions { get; init; } = [];
[JsonPropertyName("followUp")]
public ChatFollowUp? FollowUp { get; init; }
[JsonPropertyName("diagnostics")]
public ChatDiagnostics? Diagnostics { get; init; }
}
internal sealed record ChatImpactAssessment
{
[JsonPropertyName("severity")]
public string? Severity { get; init; }
[JsonPropertyName("affectedComponents")]
public List<string> AffectedComponents { get; init; } = [];
[JsonPropertyName("description")]
public string? Description { get; init; }
}
internal sealed record ChatReachabilityAssessment
{
[JsonPropertyName("reachable")]
public bool Reachable { get; init; }
[JsonPropertyName("paths")]
public List<string> Paths { get; init; } = [];
[JsonPropertyName("confidence")]
public double Confidence { get; init; }
}
internal sealed record ChatMitigationOption
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("title")]
public required string Title { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("effort")]
public string? Effort { get; init; }
[JsonPropertyName("recommended")]
public bool Recommended { get; init; }
}
internal sealed record ChatEvidenceLink
{
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("ref")]
public required string Ref { get; init; }
[JsonPropertyName("label")]
public string? Label { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
}
internal sealed record ChatConfidence
{
[JsonPropertyName("overall")]
public double Overall { get; init; }
[JsonPropertyName("evidenceQuality")]
public double EvidenceQuality { get; init; }
[JsonPropertyName("modelCertainty")]
public double ModelCertainty { get; init; }
}
internal sealed record ChatProposedAction
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("tool")]
public required string Tool { get; init; }
[JsonPropertyName("description")]
public required string Description { get; init; }
[JsonPropertyName("parameters")]
public Dictionary<string, object>? Parameters { get; init; }
[JsonPropertyName("requiresConfirmation")]
public bool RequiresConfirmation { get; init; }
[JsonPropertyName("denied")]
public bool Denied { get; init; }
[JsonPropertyName("denyReason")]
public string? DenyReason { get; init; }
}
internal sealed record ChatFollowUp
{
[JsonPropertyName("suggestedQueries")]
public List<string> SuggestedQueries { get; init; } = [];
[JsonPropertyName("relatedTopics")]
public List<string> RelatedTopics { get; init; } = [];
}
internal sealed record ChatDiagnostics
{
[JsonPropertyName("tokensUsed")]
public int TokensUsed { get; init; }
[JsonPropertyName("processingTimeMs")]
public long ProcessingTimeMs { get; init; }
[JsonPropertyName("evidenceSourcesQueried")]
public int EvidenceSourcesQueried { get; init; }
}
/// <summary>
/// Chat doctor response with quota and tool access status.
/// </summary>
internal sealed record ChatDoctorResponse
{
[JsonPropertyName("tenantId")]
public required string TenantId { get; init; }
[JsonPropertyName("userId")]
public required string UserId { get; init; }
[JsonPropertyName("quotas")]
public required ChatQuotaStatus Quotas { get; init; }
[JsonPropertyName("tools")]
public required ChatToolAccess Tools { get; init; }
[JsonPropertyName("lastDenied")]
public ChatDenialInfo? LastDenied { get; init; }
}
internal sealed record ChatQuotaStatus
{
[JsonPropertyName("requestsPerMinuteLimit")]
public int RequestsPerMinuteLimit { get; init; }
[JsonPropertyName("requestsPerMinuteRemaining")]
public int RequestsPerMinuteRemaining { get; init; }
[JsonPropertyName("requestsPerMinuteResetsAt")]
public DateTimeOffset RequestsPerMinuteResetsAt { get; init; }
[JsonPropertyName("requestsPerDayLimit")]
public int RequestsPerDayLimit { get; init; }
[JsonPropertyName("requestsPerDayRemaining")]
public int RequestsPerDayRemaining { get; init; }
[JsonPropertyName("requestsPerDayResetsAt")]
public DateTimeOffset RequestsPerDayResetsAt { get; init; }
[JsonPropertyName("tokensPerDayLimit")]
public int TokensPerDayLimit { get; init; }
[JsonPropertyName("tokensPerDayRemaining")]
public int TokensPerDayRemaining { get; init; }
[JsonPropertyName("tokensPerDayResetsAt")]
public DateTimeOffset TokensPerDayResetsAt { get; init; }
}
internal sealed record ChatToolAccess
{
[JsonPropertyName("allowAll")]
public bool AllowAll { get; init; }
[JsonPropertyName("allowedTools")]
public List<string> AllowedTools { get; init; } = [];
[JsonPropertyName("providers")]
public ChatToolProviders? Providers { get; init; }
}
internal sealed record ChatToolProviders
{
[JsonPropertyName("sbom")]
public bool Sbom { get; init; }
[JsonPropertyName("vex")]
public bool Vex { get; init; }
[JsonPropertyName("reachability")]
public bool Reachability { get; init; }
[JsonPropertyName("policy")]
public bool Policy { get; init; }
[JsonPropertyName("findings")]
public bool Findings { get; init; }
}
internal sealed record ChatDenialInfo
{
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; }
[JsonPropertyName("reason")]
public required string Reason { get; init; }
[JsonPropertyName("code")]
public string? Code { get; init; }
[JsonPropertyName("query")]
public string? Query { get; init; }
}
/// <summary>
/// Chat settings response.
/// </summary>
internal sealed record ChatSettingsResponse
{
[JsonPropertyName("tenantId")]
public required string TenantId { get; init; }
[JsonPropertyName("userId")]
public string? UserId { get; init; }
[JsonPropertyName("scope")]
public required string Scope { get; init; }
[JsonPropertyName("quotas")]
public ChatQuotaSettings? Quotas { get; init; }
[JsonPropertyName("tools")]
public ChatToolSettings? Tools { get; init; }
[JsonPropertyName("effective")]
public ChatEffectiveSettings? Effective { get; init; }
}
internal sealed record ChatQuotaSettings
{
[JsonPropertyName("requestsPerMinute")]
public int? RequestsPerMinute { get; init; }
[JsonPropertyName("requestsPerDay")]
public int? RequestsPerDay { get; init; }
[JsonPropertyName("tokensPerDay")]
public int? TokensPerDay { get; init; }
[JsonPropertyName("toolCallsPerDay")]
public int? ToolCallsPerDay { get; init; }
}
internal sealed record ChatToolSettings
{
[JsonPropertyName("allowAll")]
public bool? AllowAll { get; init; }
[JsonPropertyName("allowedTools")]
public List<string>? AllowedTools { get; init; }
}
internal sealed record ChatEffectiveSettings
{
[JsonPropertyName("quotas")]
public required ChatQuotaSettings Quotas { get; init; }
[JsonPropertyName("tools")]
public required ChatToolSettings Tools { get; init; }
[JsonPropertyName("source")]
public required string Source { get; init; }
}
/// <summary>
/// Chat settings update request.
/// </summary>
internal sealed record ChatSettingsUpdateRequest
{
[JsonPropertyName("quotas")]
public ChatQuotaSettingsUpdate? Quotas { get; init; }
[JsonPropertyName("tools")]
public ChatToolSettingsUpdate? Tools { get; init; }
}
internal sealed record ChatQuotaSettingsUpdate
{
[JsonPropertyName("requestsPerMinute")]
public int? RequestsPerMinute { get; init; }
[JsonPropertyName("requestsPerDay")]
public int? RequestsPerDay { get; init; }
[JsonPropertyName("tokensPerDay")]
public int? TokensPerDay { get; init; }
[JsonPropertyName("toolCallsPerDay")]
public int? ToolCallsPerDay { get; init; }
}
internal sealed record ChatToolSettingsUpdate
{
[JsonPropertyName("allowAll")]
public bool? AllowAll { get; init; }
[JsonPropertyName("allowedTools")]
public List<string>? AllowedTools { get; init; }
}
/// <summary>
/// Error response from chat API.
/// </summary>
internal sealed record ChatErrorResponse
{
[JsonPropertyName("error")]
public required string Error { get; init; }
[JsonPropertyName("code")]
public string? Code { get; init; }
[JsonPropertyName("details")]
public Dictionary<string, object>? Details { get; init; }
[JsonPropertyName("doctor")]
public ChatDoctorAction? Doctor { get; init; }
}
internal sealed record ChatDoctorAction
{
[JsonPropertyName("endpoint")]
public required string Endpoint { get; init; }
[JsonPropertyName("suggestedCommand")]
public required string SuggestedCommand { get; init; }
[JsonPropertyName("reason")]
public required string Reason { get; init; }
}

View File

@@ -89,9 +89,7 @@
<ProjectReference Include="../../ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.AuditPack/StellaOps.AuditPack.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj" />
<ProjectReference Include="../../Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj" />
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.StandardPredicates/StellaOps.Attestor.StandardPredicates.csproj" />
<!-- Binary Delta Signatures (SPRINT_20260102_001_BE) -->
<ProjectReference Include="../../Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj" /><!-- Binary Delta Signatures (SPRINT_20260102_001_BE) -->
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Disassembly/StellaOps.BinaryIndex.Disassembly.csproj" />
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Normalization/StellaOps.BinaryIndex.Normalization.csproj" />
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.DeltaSig/StellaOps.BinaryIndex.DeltaSig.csproj" />
@@ -155,3 +153,4 @@
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,157 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="NetEscapades.Configuration.Yaml" />
<PackageReference Include="Spectre.Console" />
<PackageReference Include="System.CommandLine" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<ItemGroup>
<Compile Remove="Commands\\BenchCommandBuilder.cs" />
<Compile Remove="Commands\\Proof\\AnchorCommandGroup.cs" />
<!-- ProofCommandGroup enabled for SPRINT_3500_0004_0001_cli_verbs T4 -->
<Compile Remove="Commands\\Proof\\ReceiptCommandGroup.cs" />
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.local.json" Condition="Exists('appsettings.local.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.yaml" Condition="Exists('appsettings.yaml')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.local.yaml" Condition="Exists('appsettings.local.yaml')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Canonicalization/StellaOps.Canonicalization.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.DeltaVerdict/StellaOps.DeltaVerdict.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Verdict/StellaOps.Verdict.csproj" />
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.EntryTrace/StellaOps.Scanner.EntryTrace.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/StellaOps.Scanner.Storage.Oci.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Node/StellaOps.Scanner.Analyzers.Lang.Node.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Python/StellaOps.Scanner.Analyzers.Lang.Python.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java/StellaOps.Scanner.Analyzers.Lang.Java.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Php/StellaOps.Scanner.Analyzers.Lang.Php.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Bun/StellaOps.Scanner.Analyzers.Lang.Bun.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
<ProjectReference Include="../../Policy/StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../../Policy/StellaOps.Policy.RiskProfile/StellaOps.Policy.RiskProfile.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Tools/StellaOps.Policy.Tools.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="../../Authority/__Libraries/StellaOps.Authority.Persistence/StellaOps.Authority.Persistence.csproj" />
<ProjectReference Include="../../Scheduler/__Libraries/StellaOps.Scheduler.Persistence/StellaOps.Scheduler.Persistence.csproj" />
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Persistence/StellaOps.Concelier.Persistence.csproj" />
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy.Persistence/StellaOps.Policy.Persistence.csproj" />
<ProjectReference Include="../../Notify/__Libraries/StellaOps.Notify.Persistence/StellaOps.Notify.Persistence.csproj" />
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj" />
<ProjectReference Include="../../Policy/StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
<ProjectReference Include="../../ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Client/StellaOps.ExportCenter.Client.csproj" />
<ProjectReference Include="../../ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.AuditPack/StellaOps.AuditPack.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj" />
<ProjectReference Include="../../Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj" />
<ProjectReference Include="../../Attestor/__Libraries/StellaOps.Attestor.StandardPredicates/StellaOps.Attestor.StandardPredicates.csproj" />
<!-- Binary Delta Signatures (SPRINT_20260102_001_BE) -->
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Disassembly/StellaOps.BinaryIndex.Disassembly.csproj" />
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.Normalization/StellaOps.BinaryIndex.Normalization.csproj" />
<ProjectReference Include="../../BinaryIndex/__Libraries/StellaOps.BinaryIndex.DeltaSig/StellaOps.BinaryIndex.DeltaSig.csproj" />
<!-- Binary Call Graph (SPRINT_20260104_001_CLI) -->
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.CallGraph/StellaOps.Scanner.CallGraph.csproj" />
<ProjectReference Include="../../Scanner/StellaOps.Scanner.Analyzers.Native/StellaOps.Scanner.Analyzers.Native.csproj" />
<!-- Secrets Bundle CLI (SPRINT_20260104_003_SCANNER) -->
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Secrets/StellaOps.Scanner.Analyzers.Secrets.csproj" />
<!-- Replay Infrastructure (SPRINT_20260105_002_001_REPLAY) -->
<ProjectReference Include="../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
<!-- Air-Gap Job Sync (SPRINT_20260105_002_003_ROUTER) -->
<ProjectReference Include="../../AirGap/__Libraries/StellaOps.AirGap.Sync/StellaOps.AirGap.Sync.csproj" />
<!-- Facet seal and drift (SPRINT_20260105_002_004_CLI) -->
<ProjectReference Include="../../__Libraries/StellaOps.Facet/StellaOps.Facet.csproj" />
<!-- GitHub Code Scanning Integration (SPRINT_20260109_010_002) -->
<ProjectReference Include="../../Integrations/__Plugins/StellaOps.Integrations.Plugin.GitHubApp/StellaOps.Integrations.Plugin.GitHubApp.csproj" />
<!-- Patch Verification (SPRINT_20260111_001_004) -->
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.PatchVerification/StellaOps.Scanner.PatchVerification.csproj" />
<!-- Change Trace (SPRINT_20260112_200_006) -->
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.ChangeTrace/StellaOps.Scanner.ChangeTrace.csproj" />
<!-- Doctor Diagnostics System -->
<ProjectReference Include="../../__Libraries/StellaOps.Doctor/StellaOps.Doctor.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Doctor.Plugins.Core/StellaOps.Doctor.Plugins.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Doctor.Plugins.Database/StellaOps.Doctor.Plugins.Database.csproj" />
</ItemGroup>
<!-- GOST Crypto Plugins (Russia distribution) -->
<ItemGroup Condition="'$(StellaOpsEnableGOST)' == 'true'">
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.OpenSslGost/StellaOps.Cryptography.Plugin.OpenSslGost.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj" />
</ItemGroup>
<!-- eIDAS Crypto Plugin (EU distribution) -->
<ItemGroup Condition="'$(StellaOpsEnableEIDAS)' == 'true'">
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.EIDAS/StellaOps.Cryptography.Plugin.EIDAS.csproj" />
</ItemGroup>
<!-- SM Crypto Plugins (China distribution) -->
<ItemGroup Condition="'$(StellaOpsEnableSM)' == 'true'">
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.SmSoft/StellaOps.Cryptography.Plugin.SmSoft.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.SmRemote/StellaOps.Cryptography.Plugin.SmRemote.csproj" />
</ItemGroup>
<!-- SM Simulator (Debug builds only, for testing) -->
<ItemGroup Condition="'$(Configuration)' == 'Debug' OR '$(StellaOpsEnableSimulator)' == 'true'">
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.SimRemote/StellaOps.Cryptography.Plugin.SimRemote.csproj" />
</ItemGroup>
<!-- Define preprocessor constants for runtime detection -->
<PropertyGroup Condition="'$(StellaOpsEnableGOST)' == 'true'">
<DefineConstants>$(DefineConstants);STELLAOPS_ENABLE_GOST</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(StellaOpsEnableEIDAS)' == 'true'">
<DefineConstants>$(DefineConstants);STELLAOPS_ENABLE_EIDAS</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(StellaOpsEnableSM)' == 'true'">
<DefineConstants>$(DefineConstants);STELLAOPS_ENABLE_SM</DefineConstants>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,252 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Cli.Commands.Setup.State;
using StellaOps.Cli.Commands.Setup.Steps;
using StellaOps.Doctor.Detection;
using Xunit;
namespace StellaOps.Cli.Commands.Setup.Tests.State;
[Trait("Category", "Unit")]
public sealed class FileSetupStateStoreTests : IDisposable
{
private readonly string _testDir;
private readonly FakeTimeProvider _timeProvider;
private readonly FileSetupStateStore _store;
public FileSetupStateStoreTests()
{
_testDir = Path.Combine(Path.GetTempPath(), $"setup-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDir);
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero));
_store = new FileSetupStateStore(_timeProvider, _testDir);
}
public void Dispose()
{
if (Directory.Exists(_testDir))
{
Directory.Delete(_testDir, true);
}
}
[Fact]
public async Task CreateSessionAsync_CreatesNewSession()
{
// Act
var session = await _store.CreateSessionAsync(RuntimeEnvironment.DockerCompose);
// Assert
session.Should().NotBeNull();
session.Id.Should().StartWith("setup-20260113");
session.Runtime.Should().Be(RuntimeEnvironment.DockerCompose);
session.Status.Should().Be(SetupSessionStatus.InProgress);
session.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
}
[Fact]
public async Task GetLatestSessionAsync_ReturnsNull_WhenNoSessions()
{
// Act
var session = await _store.GetLatestSessionAsync();
// Assert
session.Should().BeNull();
}
[Fact]
public async Task GetLatestSessionAsync_ReturnsLatestSession()
{
// Arrange
await _store.CreateSessionAsync(RuntimeEnvironment.DockerCompose);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
var laterSession = await _store.CreateSessionAsync(RuntimeEnvironment.Kubernetes);
// Act
var result = await _store.GetLatestSessionAsync();
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(laterSession.Id);
}
[Fact]
public async Task GetSessionAsync_ReturnsSession_WhenExists()
{
// Arrange
var session = await _store.CreateSessionAsync(RuntimeEnvironment.Systemd);
// Act
var result = await _store.GetSessionAsync(session.Id);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(session.Id);
}
[Fact]
public async Task GetSessionAsync_ReturnsNull_WhenNotExists()
{
// Act
var result = await _store.GetSessionAsync("nonexistent-session");
// Assert
result.Should().BeNull();
}
[Fact]
public async Task ListSessionsAsync_ReturnsAllSessions()
{
// Arrange
await _store.CreateSessionAsync(RuntimeEnvironment.DockerCompose);
_timeProvider.Advance(TimeSpan.FromMinutes(1));
await _store.CreateSessionAsync(RuntimeEnvironment.Kubernetes);
// Act
var sessions = await _store.ListSessionsAsync();
// Assert
sessions.Should().HaveCount(2);
}
[Fact]
public async Task SaveStepResultAsync_SavesResult()
{
// Arrange
var session = await _store.CreateSessionAsync(RuntimeEnvironment.Bare);
var result = SetupStepResult.Success("Test completed");
// Act
await _store.SaveStepResultAsync(session.Id, "database", result);
// Assert
var results = await _store.GetStepResultsAsync(session.Id);
results.Should().ContainKey("database");
results["database"].Status.Should().Be(SetupStepStatus.Completed);
}
[Fact]
public async Task SaveStepResultAsync_UpdatesSessionMetadata()
{
// Arrange
var session = await _store.CreateSessionAsync(RuntimeEnvironment.Bare);
var result = SetupStepResult.Success();
// Act
await _store.SaveStepResultAsync(session.Id, "database", result);
// Assert
var updatedSession = await _store.GetSessionAsync(session.Id);
updatedSession!.LastStepId.Should().Be("database");
updatedSession.UpdatedAt.Should().NotBeNull();
}
[Fact]
public async Task CompleteSessionAsync_UpdatesStatus()
{
// Arrange
var session = await _store.CreateSessionAsync(RuntimeEnvironment.DockerCompose);
// Act
await _store.CompleteSessionAsync(session.Id);
// Assert
var result = await _store.GetSessionAsync(session.Id);
result!.Status.Should().Be(SetupSessionStatus.Completed);
result.CompletedAt.Should().NotBeNull();
}
[Fact]
public async Task FailSessionAsync_UpdatesStatusWithError()
{
// Arrange
var session = await _store.CreateSessionAsync(RuntimeEnvironment.DockerCompose);
// Act
await _store.FailSessionAsync(session.Id, "Connection failed");
// Assert
var result = await _store.GetSessionAsync(session.Id);
result!.Status.Should().Be(SetupSessionStatus.Failed);
result.Error.Should().Be("Connection failed");
}
[Fact]
public async Task ResetStepAsync_RemovesStepResult()
{
// Arrange
var session = await _store.CreateSessionAsync(RuntimeEnvironment.Bare);
await _store.SaveStepResultAsync(session.Id, "database", SetupStepResult.Success());
await _store.SaveStepResultAsync(session.Id, "cache", SetupStepResult.Success());
// Act
await _store.ResetStepAsync(session.Id, "database");
// Assert
var results = await _store.GetStepResultsAsync(session.Id);
results.Should().NotContainKey("database");
results.Should().ContainKey("cache");
}
[Fact]
public async Task DeleteSessionAsync_RemovesSession()
{
// Arrange
var session = await _store.CreateSessionAsync(RuntimeEnvironment.Bare);
// Act
await _store.DeleteSessionAsync(session.Id);
// Assert
var result = await _store.GetSessionAsync(session.Id);
result.Should().BeNull();
}
[Fact]
public async Task DeleteAllSessionsAsync_RemovesAllSessions()
{
// Arrange
await _store.CreateSessionAsync(RuntimeEnvironment.DockerCompose);
await _store.CreateSessionAsync(RuntimeEnvironment.Kubernetes);
// Act
await _store.DeleteAllSessionsAsync();
// Assert
var sessions = await _store.ListSessionsAsync();
sessions.Should().BeEmpty();
}
[Fact]
public async Task SaveConfigValuesAsync_StoresValues()
{
// Arrange
var session = await _store.CreateSessionAsync(RuntimeEnvironment.Bare);
var values = new Dictionary<string, string>
{
["database.host"] = "localhost",
["database.port"] = "5432"
};
// Act
await _store.SaveConfigValuesAsync(session.Id, values);
// Assert
var result = await _store.GetConfigValuesAsync(session.Id);
result.Should().HaveCount(2);
result["database.host"].Should().Be("localhost");
}
[Fact]
public async Task GetConfigValuesAsync_ReturnsEmpty_WhenNoValues()
{
// Arrange
var session = await _store.CreateSessionAsync(RuntimeEnvironment.Bare);
// Act
var result = await _store.GetConfigValuesAsync(session.Id);
// Assert
result.Should().BeEmpty();
}
}

View File

@@ -5,19 +5,12 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,911 @@
using FluentAssertions;
using StellaOps.Cli.Commands.Setup.Steps;
using StellaOps.Cli.Commands.Setup.Steps.Implementations;
using StellaOps.Doctor.Detection;
using Xunit;
namespace StellaOps.Cli.Commands.Setup.Tests.Steps;
[Trait("Category", "Unit")]
public sealed class SetupStepImplementationsTests
{
[Fact]
public void DatabaseSetupStep_HasCorrectMetadata()
{
// Arrange
var step = new DatabaseSetupStep();
// Assert
step.Id.Should().Be("database");
step.Name.Should().Be("PostgreSQL Database");
step.Category.Should().Be(SetupCategory.Infrastructure);
step.IsRequired.Should().BeTrue();
step.IsSkippable.Should().BeFalse();
step.Order.Should().Be(10);
step.Dependencies.Should().BeEmpty();
step.ValidationChecks.Should().Contain("check.database.connectivity");
}
[Fact]
public void CacheSetupStep_HasCorrectMetadata()
{
// Arrange
var step = new CacheSetupStep();
// Assert
step.Id.Should().Be("cache");
step.Name.Should().Be("Valkey/Redis Cache");
step.Category.Should().Be(SetupCategory.Infrastructure);
step.IsRequired.Should().BeTrue();
step.Dependencies.Should().Contain("database");
step.Order.Should().Be(20);
}
[Fact]
public void VaultSetupStep_HasCorrectMetadata()
{
// Arrange
var step = new VaultSetupStep();
// Assert
step.Id.Should().Be("vault");
step.Name.Should().Be("Secrets Vault");
step.Category.Should().Be(SetupCategory.Security);
step.IsRequired.Should().BeFalse();
step.IsSkippable.Should().BeTrue();
step.ValidationChecks.Should().Contain("check.integration.vault.connectivity");
}
[Fact]
public void SettingsStoreSetupStep_HasCorrectMetadata()
{
// Arrange
var step = new SettingsStoreSetupStep();
// Assert
step.Id.Should().Be("settingsstore");
step.Name.Should().Be("Settings Store");
step.Category.Should().Be(SetupCategory.Configuration);
step.IsRequired.Should().BeFalse();
step.IsSkippable.Should().BeTrue();
step.ValidationChecks.Should().Contain("check.integration.settingsstore.connectivity");
}
[Fact]
public void RegistrySetupStep_HasCorrectMetadata()
{
// Arrange
var step = new RegistrySetupStep();
// Assert
step.Id.Should().Be("registry");
step.Name.Should().Be("Container Registry");
step.Category.Should().Be(SetupCategory.Integration);
step.IsRequired.Should().BeFalse();
step.ValidationChecks.Should().Contain("check.integration.registry.connectivity");
}
[Fact]
public void TelemetrySetupStep_HasCorrectMetadata()
{
// Arrange
var step = new TelemetrySetupStep();
// Assert
step.Id.Should().Be("telemetry");
step.Name.Should().Be("OpenTelemetry");
step.Category.Should().Be(SetupCategory.Observability);
step.IsRequired.Should().BeFalse();
step.ValidationChecks.Should().Contain("check.telemetry.otlp.connectivity");
}
[Fact]
public async Task DatabaseSetupStep_CheckPrerequisites_Passes_WhenInteractive()
{
// Arrange
var step = new DatabaseSetupStep();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
NonInteractive = false
};
// Act
var result = await step.CheckPrerequisitesAsync(context);
// Assert
result.Met.Should().BeTrue();
}
[Fact]
public async Task DatabaseSetupStep_CheckPrerequisites_Fails_WhenNonInteractiveWithoutConfig()
{
// Arrange
var step = new DatabaseSetupStep();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
NonInteractive = true
};
// Act
var result = await step.CheckPrerequisitesAsync(context);
// Assert
result.Met.Should().BeFalse();
result.MissingPrerequisites.Should().Contain(s => s.Contains("database"));
}
[Fact]
public async Task DatabaseSetupStep_CheckPrerequisites_Passes_WhenNonInteractiveWithConnectionString()
{
// Arrange
var step = new DatabaseSetupStep();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
NonInteractive = true,
ConfigValues = new Dictionary<string, string>
{
["database.connectionString"] = "Host=localhost;Database=test"
}
};
// Act
var result = await step.CheckPrerequisitesAsync(context);
// Assert
result.Met.Should().BeTrue();
}
[Fact]
public async Task DatabaseSetupStep_Execute_DryRun_ReturnsSuccess()
{
// Arrange
var step = new DatabaseSetupStep();
var output = new List<string>();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
DryRun = true,
ConfigValues = new Dictionary<string, string>
{
["database.host"] = "localhost",
["database.port"] = "5432",
["database.database"] = "testdb",
["database.user"] = "testuser",
["database.password"] = "testpass"
},
Output = msg => output.Add(msg)
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Completed);
result.AppliedConfig.Should().ContainKey("database.host");
output.Should().Contain(s => s.Contains("DRY RUN"));
}
[Fact]
public async Task CacheSetupStep_CheckPrerequisites_Fails_WhenNonInteractiveWithoutConfig()
{
// Arrange
var step = new CacheSetupStep();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
NonInteractive = true
};
// Act
var result = await step.CheckPrerequisitesAsync(context);
// Assert
result.Met.Should().BeFalse();
}
[Fact]
public async Task CacheSetupStep_Execute_DryRun_ReturnsSuccess()
{
// Arrange
var step = new CacheSetupStep();
var output = new List<string>();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
DryRun = true,
ConfigValues = new Dictionary<string, string>
{
["cache.host"] = "localhost",
["cache.port"] = "6379"
},
Output = msg => output.Add(msg)
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Completed);
result.AppliedConfig.Should().ContainKey("cache.host");
output.Should().Contain(s => s.Contains("DRY RUN"));
}
[Fact]
public async Task SettingsStoreSetupStep_Execute_ReturnsSkipped_WhenNoProviderSelected()
{
// Arrange
var step = new SettingsStoreSetupStep();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
NonInteractive = true // No provider in config, non-interactive mode
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Skipped);
}
[Fact]
public async Task SettingsStoreSetupStep_Execute_DryRun_ConfiguresConsul()
{
// Arrange
var step = new SettingsStoreSetupStep();
var output = new List<string>();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
DryRun = true,
ConfigValues = new Dictionary<string, string>
{
["settingsstore.provider"] = "consul",
["settingsstore.address"] = "http://localhost:8500",
["settingsstore.prefix"] = "stellaops/"
},
Output = msg => output.Add(msg)
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Completed);
result.AppliedConfig["settingsstore.provider"].Should().Be("consul");
output.Should().Contain(s => s.Contains("DRY RUN"));
}
[Fact]
public async Task VaultSetupStep_Execute_ReturnsSkipped_WhenNoProviderSelected()
{
// Arrange
var step = new VaultSetupStep();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
NonInteractive = true
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Skipped);
}
[Fact]
public async Task TelemetrySetupStep_Execute_ReturnsSkipped_WhenNoEndpointProvided()
{
// Arrange
var step = new TelemetrySetupStep();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
NonInteractive = true
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Skipped);
}
[Fact]
public async Task TelemetrySetupStep_Execute_DryRun_ReturnsSuccess()
{
// Arrange
var step = new TelemetrySetupStep();
var output = new List<string>();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
DryRun = true,
ConfigValues = new Dictionary<string, string>
{
["telemetry.otlpEndpoint"] = "http://localhost:4317",
["telemetry.serviceName"] = "test-service"
},
Output = msg => output.Add(msg)
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Completed);
result.AppliedConfig["telemetry.otlpEndpoint"].Should().Be("http://localhost:4317");
output.Should().Contain(s => s.Contains("DRY RUN"));
}
[Fact]
public async Task RegistrySetupStep_Execute_ReturnsSkipped_WhenNoUrlProvided()
{
// Arrange
var step = new RegistrySetupStep();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
NonInteractive = true
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Skipped);
}
[Fact]
public async Task RegistrySetupStep_Execute_DryRun_ReturnsSuccess()
{
// Arrange
var step = new RegistrySetupStep();
var output = new List<string>();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
DryRun = true,
ConfigValues = new Dictionary<string, string>
{
["registry.url"] = "https://registry.example.com",
["registry.username"] = "admin"
},
Output = msg => output.Add(msg)
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Completed);
result.AppliedConfig["registry.url"].Should().Be("https://registry.example.com");
output.Should().Contain(s => s.Contains("DRY RUN"));
}
[Fact]
public void AllSteps_HaveUniqueIds()
{
// Arrange
var steps = new ISetupStep[]
{
new DatabaseSetupStep(),
new CacheSetupStep(),
new VaultSetupStep(),
new SettingsStoreSetupStep(),
new RegistrySetupStep(),
new TelemetrySetupStep()
};
// Assert
var ids = steps.Select(s => s.Id).ToList();
ids.Should().OnlyHaveUniqueItems();
}
[Fact]
public void AllSteps_CanBeAddedToCatalog()
{
// Arrange
var catalog = new SetupStepCatalog();
var steps = new ISetupStep[]
{
new DatabaseSetupStep(),
new CacheSetupStep(),
new VaultSetupStep(),
new SettingsStoreSetupStep(),
new RegistrySetupStep(),
new TelemetrySetupStep()
};
// Act
foreach (var step in steps)
{
catalog.Register(step);
}
// Assert
catalog.AllSteps.Should().HaveCount(6);
}
[Fact]
public void StepCatalog_ResolvesExecutionOrder_WithDependencies()
{
// Arrange
var catalog = new SetupStepCatalog();
catalog.Register(new DatabaseSetupStep());
catalog.Register(new CacheSetupStep());
catalog.Register(new VaultSetupStep());
catalog.Register(new SettingsStoreSetupStep());
// Act
var orderedSteps = catalog.ResolveExecutionOrder().ToList();
// Assert
orderedSteps.Should().HaveCount(4);
// Database must come before Cache (Cache depends on Database)
var databaseIndex = orderedSteps.FindIndex(s => s.Id == "database");
var cacheIndex = orderedSteps.FindIndex(s => s.Id == "cache");
databaseIndex.Should().BeLessThan(cacheIndex);
}
// =====================================
// Sprint 7-9 Setup Steps Tests
// =====================================
[Fact]
public void AuthoritySetupStep_HasCorrectMetadata()
{
// Arrange
var step = new AuthoritySetupStep();
// Assert
step.Id.Should().Be("authority");
step.Name.Should().Be("Authentication Provider");
step.Category.Should().Be(SetupCategory.Security);
step.IsRequired.Should().BeTrue();
step.IsSkippable.Should().BeFalse();
step.Order.Should().Be(1);
step.ValidationChecks.Should().Contain("check.authority.plugin.configured");
}
[Fact]
public async Task AuthoritySetupStep_CheckPrerequisites_Passes_WhenInteractive()
{
// Arrange
var step = new AuthoritySetupStep();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
NonInteractive = false
};
// Act
var result = await step.CheckPrerequisitesAsync(context);
// Assert
result.Met.Should().BeTrue();
}
[Fact]
public async Task AuthoritySetupStep_Execute_DryRun_ReturnsSuccess()
{
// Arrange
var step = new AuthoritySetupStep();
var output = new List<string>();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
DryRun = true,
ConfigValues = new Dictionary<string, string>
{
["authority.provider"] = "standard",
["authority.standard.passwordPolicy.minLength"] = "12"
},
Output = msg => output.Add(msg)
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Completed);
result.AppliedConfig.Should().ContainKey("authority.provider");
output.Should().Contain(s => s.Contains("DRY RUN"));
}
[Fact]
public void UsersSetupStep_HasCorrectMetadata()
{
// Arrange
var step = new UsersSetupStep();
// Assert
step.Id.Should().Be("users");
step.Name.Should().Be("User Management");
step.Category.Should().Be(SetupCategory.Security);
step.IsRequired.Should().BeTrue();
step.IsSkippable.Should().BeFalse();
step.Order.Should().Be(2);
step.Dependencies.Should().Contain("authority");
step.ValidationChecks.Should().Contain("check.users.superuser.exists");
}
[Fact]
public async Task UsersSetupStep_Execute_DryRun_ReturnsSuccess()
{
// Arrange
var step = new UsersSetupStep();
var output = new List<string>();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
DryRun = true,
ConfigValues = new Dictionary<string, string>
{
["users.superuser.username"] = "admin",
["users.superuser.email"] = "admin@example.com",
["users.superuser.password"] = "SecurePass123!"
},
Output = msg => output.Add(msg)
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Completed);
result.AppliedConfig.Should().ContainKey("users.superuser.username");
output.Should().Contain(s => s.Contains("DRY RUN"));
}
[Fact]
public void NotifySetupStep_HasCorrectMetadata()
{
// Arrange
var step = new NotifySetupStep();
// Assert
step.Id.Should().Be("notify");
step.Name.Should().Be("Notifications");
step.Category.Should().Be(SetupCategory.Integration);
step.IsRequired.Should().BeFalse();
step.IsSkippable.Should().BeTrue();
step.Order.Should().Be(70);
step.ValidationChecks.Should().Contain("check.notify.channel.configured");
}
[Fact]
public async Task NotifySetupStep_Execute_ReturnsSkipped_WhenNoProviderSelected()
{
// Arrange
var step = new NotifySetupStep();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
NonInteractive = true
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Skipped);
}
[Fact]
public async Task NotifySetupStep_Execute_DryRun_ConfiguresEmail()
{
// Arrange
var step = new NotifySetupStep();
var output = new List<string>();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
DryRun = true,
ConfigValues = new Dictionary<string, string>
{
["notify.provider"] = "email",
["notify.email.smtpHost"] = "smtp.example.com",
["notify.email.smtpPort"] = "587",
["notify.email.fromAddress"] = "noreply@example.com"
},
Output = msg => output.Add(msg)
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Completed);
result.AppliedConfig["notify.provider"].Should().Be("email");
output.Should().Contain(s => s.Contains("DRY RUN"));
}
[Fact]
public void LlmSetupStep_HasCorrectMetadata()
{
// Arrange
var step = new LlmSetupStep();
// Assert
step.Id.Should().Be("llm");
step.Name.Should().Be("AI/LLM Provider");
step.Category.Should().Be(SetupCategory.Integration);
step.IsRequired.Should().BeFalse();
step.IsSkippable.Should().BeTrue();
step.Order.Should().Be(80);
step.ValidationChecks.Should().Contain("check.ai.llm.config");
step.ValidationChecks.Should().Contain("check.ai.provider.openai");
step.ValidationChecks.Should().Contain("check.ai.provider.claude");
step.ValidationChecks.Should().Contain("check.ai.provider.gemini");
}
[Fact]
public async Task LlmSetupStep_CheckPrerequisites_AlwaysPasses()
{
// Arrange
var step = new LlmSetupStep();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
NonInteractive = true
};
// Act
var result = await step.CheckPrerequisitesAsync(context);
// Assert - LLM setup has no prerequisites
result.Met.Should().BeTrue();
}
[Fact]
public async Task LlmSetupStep_Execute_ReturnsSuccess_WhenNoneSelected()
{
// Arrange
var step = new LlmSetupStep();
var output = new List<string>();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
NonInteractive = false,
ConfigValues = new Dictionary<string, string>
{
["llm.provider"] = "none"
},
Output = msg => output.Add(msg),
PromptForChoice = (prompt, options, defaultVal) => "none"
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Completed);
result.AppliedConfig.Should().ContainKey("AdvisoryAI:Enabled");
result.AppliedConfig["AdvisoryAI:Enabled"].Should().Be("false");
}
[Fact]
public async Task LlmSetupStep_Execute_DryRun_ConfiguresOpenAi()
{
// Arrange
var step = new LlmSetupStep();
var output = new List<string>();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
DryRun = true,
ConfigValues = new Dictionary<string, string>
{
["llm.provider"] = "openai",
["llm.openai.apiKey"] = "sk-test-key-12345",
["llm.openai.model"] = "gpt-4o"
},
Output = msg => output.Add(msg)
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Completed);
result.AppliedConfig["AdvisoryAI:Enabled"].Should().Be("true");
result.AppliedConfig["AdvisoryAI:DefaultProvider"].Should().Be("openai");
result.AppliedConfig["AdvisoryAI:LlmProviders:OpenAI:Model"].Should().Be("gpt-4o");
output.Should().Contain(s => s.Contains("DRY RUN"));
}
[Fact]
public async Task LlmSetupStep_Execute_DryRun_ConfiguresClaude()
{
// Arrange
var step = new LlmSetupStep();
var output = new List<string>();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
DryRun = true,
ConfigValues = new Dictionary<string, string>
{
["llm.provider"] = "claude",
["llm.claude.apiKey"] = "sk-ant-test-key-12345",
["llm.claude.model"] = "claude-sonnet-4-20250514"
},
Output = msg => output.Add(msg)
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Completed);
result.AppliedConfig["AdvisoryAI:Enabled"].Should().Be("true");
result.AppliedConfig["AdvisoryAI:DefaultProvider"].Should().Be("claude");
result.AppliedConfig["AdvisoryAI:LlmProviders:Claude:Model"].Should().Be("claude-sonnet-4-20250514");
output.Should().Contain(s => s.Contains("DRY RUN"));
}
[Fact]
public async Task LlmSetupStep_Execute_DryRun_ConfiguresGemini()
{
// Arrange
var step = new LlmSetupStep();
var output = new List<string>();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
DryRun = true,
ConfigValues = new Dictionary<string, string>
{
["llm.provider"] = "gemini",
["llm.gemini.apiKey"] = "AIzaSy-test-key-12345",
["llm.gemini.model"] = "gemini-1.5-flash"
},
Output = msg => output.Add(msg)
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Completed);
result.AppliedConfig["AdvisoryAI:Enabled"].Should().Be("true");
result.AppliedConfig["AdvisoryAI:DefaultProvider"].Should().Be("gemini");
result.AppliedConfig["AdvisoryAI:LlmProviders:Gemini:Model"].Should().Be("gemini-1.5-flash");
output.Should().Contain(s => s.Contains("DRY RUN"));
}
[Fact]
public async Task LlmSetupStep_Execute_DryRun_ConfiguresOllama()
{
// Arrange
var step = new LlmSetupStep();
var output = new List<string>();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
DryRun = true,
ConfigValues = new Dictionary<string, string>
{
["llm.provider"] = "ollama",
["llm.ollama.endpoint"] = "http://localhost:11434",
["llm.ollama.model"] = "llama3:8b"
},
Output = msg => output.Add(msg)
};
// Act
var result = await step.ExecuteAsync(context);
// Assert
result.Status.Should().Be(SetupStepStatus.Completed);
result.AppliedConfig["AdvisoryAI:Enabled"].Should().Be("true");
result.AppliedConfig["AdvisoryAI:DefaultProvider"].Should().Be("ollama");
result.AppliedConfig["AdvisoryAI:LlmProviders:Ollama:Enabled"].Should().Be("true");
result.AppliedConfig["AdvisoryAI:LlmProviders:Ollama:Endpoint"].Should().Be("http://localhost:11434");
output.Should().Contain(s => s.Contains("DRY RUN"));
}
[Fact]
public async Task LlmSetupStep_Validate_ReturnsSuccess_WhenDisabled()
{
// Arrange
var step = new LlmSetupStep();
var context = new SetupStepContext
{
SessionId = "test-session",
Runtime = RuntimeEnvironment.Bare,
ConfigValues = new Dictionary<string, string>
{
["AdvisoryAI:Enabled"] = "false"
}
};
// Act
var result = await step.ValidateAsync(context);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void AllSetupSteps_HaveUniqueIds_IncludingSprint7_9Steps()
{
// Arrange
var steps = new ISetupStep[]
{
new DatabaseSetupStep(),
new CacheSetupStep(),
new VaultSetupStep(),
new SettingsStoreSetupStep(),
new RegistrySetupStep(),
new TelemetrySetupStep(),
new AuthoritySetupStep(),
new UsersSetupStep(),
new NotifySetupStep(),
new LlmSetupStep()
};
// Assert
var ids = steps.Select(s => s.Id).ToList();
ids.Should().OnlyHaveUniqueItems();
}
[Fact]
public void StepCatalog_ResolvesExecutionOrder_WithAllSteps()
{
// Arrange
var catalog = new SetupStepCatalog();
catalog.Register(new AuthoritySetupStep());
catalog.Register(new UsersSetupStep());
catalog.Register(new DatabaseSetupStep());
catalog.Register(new CacheSetupStep());
catalog.Register(new NotifySetupStep());
catalog.Register(new LlmSetupStep());
// Act
var orderedSteps = catalog.ResolveExecutionOrder().ToList();
// Assert
orderedSteps.Should().HaveCount(6);
// Authority must come before Users (Users depends on Authority)
var authorityIndex = orderedSteps.FindIndex(s => s.Id == "authority");
var usersIndex = orderedSteps.FindIndex(s => s.Id == "users");
authorityIndex.Should().BeLessThan(usersIndex);
// Database must come before Cache (Cache depends on Database)
var databaseIndex = orderedSteps.FindIndex(s => s.Id == "database");
var cacheIndex = orderedSteps.FindIndex(s => s.Id == "cache");
databaseIndex.Should().BeLessThan(cacheIndex);
}
}

View File

@@ -0,0 +1 @@
This project causes MSBuild hang due to deep dependency tree. Build individually with: dotnet build StellaOps.Cli.Tests.csproj

View File

@@ -0,0 +1,404 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Commands.Advise;
using StellaOps.Cli.Services.Models.Chat;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
[Trait("Category", "Unit")]
public sealed class AdviseChatCommandTests
{
[Fact]
public async Task RenderQueryResponse_Table_RendersCorrectFormat()
{
// Arrange
var response = CreateSampleQueryResponse();
var sb = new StringBuilder();
await using var writer = new StringWriter(sb);
// Act
await ChatRenderer.RenderQueryResponseAsync(response, ChatOutputFormat.Table, writer, CancellationToken.None);
var output = sb.ToString();
// Assert
Assert.Contains("=== Advisory Chat Response ===", output);
Assert.Contains("Response ID: resp-123", output);
Assert.Contains("Intent:", output);
Assert.Contains("vulnerability_query", output);
Assert.Contains("This is a test summary response.", output);
Assert.Contains("--- Mitigations ---", output);
Assert.Contains("[MIT-001] Update Package", output);
Assert.Contains("[RECOMMENDED]", output);
Assert.Contains("--- Evidence ---", output);
Assert.Contains("[sbom] SBOM Reference", output);
}
[Fact]
public async Task RenderQueryResponse_Json_ReturnsValidJson()
{
// Arrange
var response = CreateSampleQueryResponse();
var sb = new StringBuilder();
await using var writer = new StringWriter(sb);
// Act
await ChatRenderer.RenderQueryResponseAsync(response, ChatOutputFormat.Json, writer, CancellationToken.None);
var output = sb.ToString();
// Assert
Assert.Contains("\"responseId\"", output);
Assert.Contains("\"resp-123\"", output);
Assert.Contains("\"intent\"", output);
Assert.Contains("\"vulnerability_query\"", output);
Assert.Contains("\"summary\"", output);
Assert.Contains("\"mitigations\"", output);
}
[Fact]
public async Task RenderQueryResponse_Markdown_RendersHeadings()
{
// Arrange
var response = CreateSampleQueryResponse();
var sb = new StringBuilder();
await using var writer = new StringWriter(sb);
// Act
await ChatRenderer.RenderQueryResponseAsync(response, ChatOutputFormat.Markdown, writer, CancellationToken.None);
var output = sb.ToString();
// Assert
Assert.Contains("# Advisory Chat Response", output);
Assert.Contains("## Summary", output);
Assert.Contains("## Mitigations", output);
Assert.Contains("## Evidence", output);
Assert.Contains("**(Recommended)**", output);
Assert.Contains("| Type | Reference | Label |", output);
}
[Fact]
public async Task RenderDoctorResponse_Table_ShowsQuotasAndTools()
{
// Arrange
var response = CreateSampleDoctorResponse();
var sb = new StringBuilder();
await using var writer = new StringWriter(sb);
// Act
await ChatRenderer.RenderDoctorResponseAsync(response, ChatOutputFormat.Table, writer, CancellationToken.None);
var output = sb.ToString();
// Assert
Assert.Contains("=== Advisory Chat Doctor ===", output);
Assert.Contains("Tenant: tenant-001", output);
Assert.Contains("User: user-001", output);
Assert.Contains("--- Quotas ---", output);
Assert.Contains("Requests/Minute:", output);
Assert.Contains("--- Tool Access ---", output);
Assert.Contains("Allow All: No", output);
Assert.Contains("SBOM:", output);
Assert.Contains("VEX:", output);
}
[Fact]
public async Task RenderDoctorResponse_Json_ReturnsValidJson()
{
// Arrange
var response = CreateSampleDoctorResponse();
var sb = new StringBuilder();
await using var writer = new StringWriter(sb);
// Act
await ChatRenderer.RenderDoctorResponseAsync(response, ChatOutputFormat.Json, writer, CancellationToken.None);
var output = sb.ToString();
// Assert
Assert.Contains("\"tenantId\"", output);
Assert.Contains("\"tenant-001\"", output);
Assert.Contains("\"quotas\"", output);
Assert.Contains("\"requestsPerMinuteLimit\"", output);
Assert.Contains("\"tools\"", output);
}
[Fact]
public async Task RenderSettingsResponse_Table_ShowsEffectiveSettings()
{
// Arrange
var response = CreateSampleSettingsResponse();
var sb = new StringBuilder();
await using var writer = new StringWriter(sb);
// Act
await ChatRenderer.RenderSettingsResponseAsync(response, ChatOutputFormat.Table, writer, CancellationToken.None);
var output = sb.ToString();
// Assert
Assert.Contains("=== Advisory Chat Settings ===", output);
Assert.Contains("Tenant: tenant-001", output);
Assert.Contains("Scope: effective", output);
Assert.Contains("--- Effective Settings ---", output);
Assert.Contains("Source: environment", output);
Assert.Contains("Quotas:", output);
Assert.Contains("Requests/Minute:", output);
}
[Fact]
public async Task RenderSettingsResponse_Json_ReturnsValidJson()
{
// Arrange
var response = CreateSampleSettingsResponse();
var sb = new StringBuilder();
await using var writer = new StringWriter(sb);
// Act
await ChatRenderer.RenderSettingsResponseAsync(response, ChatOutputFormat.Json, writer, CancellationToken.None);
var output = sb.ToString();
// Assert
Assert.Contains("\"tenantId\"", output);
Assert.Contains("\"tenant-001\"", output);
Assert.Contains("\"effective\"", output);
Assert.Contains("\"quotas\"", output);
}
[Fact]
public async Task RenderQueryResponse_WithDeniedActions_ShowsDenialReason()
{
// Arrange
var response = new ChatQueryResponse
{
ResponseId = "resp-denied",
Intent = "action_request",
GeneratedAt = DateTimeOffset.UtcNow,
Summary = "Action was denied.",
Confidence = new ChatConfidence { Overall = 0.9, EvidenceQuality = 0.85, ModelCertainty = 0.95 },
ProposedActions =
[
new ChatProposedAction
{
Id = "ACT-001",
Tool = "vex.update",
Description = "Update VEX document",
Denied = true,
DenyReason = "Tool not in allowlist",
RequiresConfirmation = false
}
]
};
var sb = new StringBuilder();
await using var writer = new StringWriter(sb);
// Act
await ChatRenderer.RenderQueryResponseAsync(response, ChatOutputFormat.Table, writer, CancellationToken.None);
var output = sb.ToString();
// Assert
Assert.Contains("--- Proposed Actions ---", output);
Assert.Contains("[DENIED]", output);
Assert.Contains("Reason: Tool not in allowlist", output);
}
[Fact]
public async Task RenderDoctorResponse_WithLastDenial_ShowsDenialInfo()
{
// Arrange
var response = new ChatDoctorResponse
{
TenantId = "tenant-001",
UserId = "user-001",
Quotas = new ChatQuotaStatus
{
RequestsPerMinuteLimit = 10,
RequestsPerMinuteRemaining = 0,
RequestsPerMinuteResetsAt = DateTimeOffset.UtcNow.AddMinutes(1),
RequestsPerDayLimit = 100,
RequestsPerDayRemaining = 50,
RequestsPerDayResetsAt = DateTimeOffset.UtcNow.AddHours(12),
TokensPerDayLimit = 50000,
TokensPerDayRemaining = 25000,
TokensPerDayResetsAt = DateTimeOffset.UtcNow.AddHours(12)
},
Tools = new ChatToolAccess
{
AllowAll = false,
AllowedTools = ["sbom.read", "vex.query"]
},
LastDenied = new ChatDenialInfo
{
Timestamp = DateTimeOffset.UtcNow.AddMinutes(-5),
Reason = "Quota exceeded",
Code = "QUOTA_EXCEEDED",
Query = "What vulnerabilities affect my image?"
}
};
var sb = new StringBuilder();
await using var writer = new StringWriter(sb);
// Act
await ChatRenderer.RenderDoctorResponseAsync(response, ChatOutputFormat.Table, writer, CancellationToken.None);
var output = sb.ToString();
// Assert
Assert.Contains("--- Last Denial ---", output);
Assert.Contains("Reason: Quota exceeded", output);
Assert.Contains("Code: QUOTA_EXCEEDED", output);
Assert.Contains("Query: What vulnerabilities affect my image?", output);
}
private static ChatQueryResponse CreateSampleQueryResponse()
{
return new ChatQueryResponse
{
ResponseId = "resp-123",
BundleId = "bundle-456",
Intent = "vulnerability_query",
GeneratedAt = DateTimeOffset.UtcNow,
Summary = "This is a test summary response.",
Impact = new ChatImpactAssessment
{
Severity = "High",
AffectedComponents = ["component-a", "component-b"],
Description = "Critical vulnerability in component-a."
},
Reachability = new ChatReachabilityAssessment
{
Reachable = true,
Paths = ["/app/main.js -> /lib/vulnerable.js"],
Confidence = 0.92
},
Mitigations =
[
new ChatMitigationOption
{
Id = "MIT-001",
Title = "Update Package",
Description = "Update the vulnerable package to the latest version.",
Effort = "Low",
Recommended = true
},
new ChatMitigationOption
{
Id = "MIT-002",
Title = "Apply Workaround",
Description = "Disable the affected feature temporarily.",
Effort = "Medium",
Recommended = false
}
],
EvidenceLinks =
[
new ChatEvidenceLink
{
Type = "sbom",
Ref = "sbom:sha256:abc123",
Label = "SBOM Reference"
},
new ChatEvidenceLink
{
Type = "vex",
Ref = "vex:sha256:def456",
Label = "VEX Document"
}
],
Confidence = new ChatConfidence
{
Overall = 0.87,
EvidenceQuality = 0.9,
ModelCertainty = 0.85
},
ProposedActions =
[
new ChatProposedAction
{
Id = "ACT-001",
Tool = "sbom.read",
Description = "Read SBOM details",
RequiresConfirmation = false,
Denied = false
}
],
FollowUp = new ChatFollowUp
{
SuggestedQueries =
[
"What is the CVE severity?",
"Are there any patches available?"
],
RelatedTopics = ["CVE-2024-1234", "npm:lodash"]
},
Diagnostics = new ChatDiagnostics
{
TokensUsed = 1500,
ProcessingTimeMs = 250,
EvidenceSourcesQueried = 3
}
};
}
private static ChatDoctorResponse CreateSampleDoctorResponse()
{
return new ChatDoctorResponse
{
TenantId = "tenant-001",
UserId = "user-001",
Quotas = new ChatQuotaStatus
{
RequestsPerMinuteLimit = 10,
RequestsPerMinuteRemaining = 8,
RequestsPerMinuteResetsAt = DateTimeOffset.UtcNow.AddSeconds(45),
RequestsPerDayLimit = 100,
RequestsPerDayRemaining = 75,
RequestsPerDayResetsAt = DateTimeOffset.UtcNow.AddHours(12),
TokensPerDayLimit = 50000,
TokensPerDayRemaining = 35000,
TokensPerDayResetsAt = DateTimeOffset.UtcNow.AddHours(12)
},
Tools = new ChatToolAccess
{
AllowAll = false,
AllowedTools = ["sbom.read", "vex.query", "findings.topk"],
Providers = new ChatToolProviders
{
Sbom = true,
Vex = true,
Reachability = true,
Policy = false,
Findings = true
}
}
};
}
private static ChatSettingsResponse CreateSampleSettingsResponse()
{
return new ChatSettingsResponse
{
TenantId = "tenant-001",
UserId = "user-001",
Scope = "effective",
Effective = new ChatEffectiveSettings
{
Quotas = new ChatQuotaSettings
{
RequestsPerMinute = 10,
RequestsPerDay = 100,
TokensPerDay = 50000,
ToolCallsPerDay = 500
},
Tools = new ChatToolSettings
{
AllowAll = false,
AllowedTools = ["sbom.read", "vex.query"]
},
Source = "environment"
}
};
}
}

View File

@@ -1,132 +0,0 @@
using System.CommandLine;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Commands.Scan;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
public sealed class BinaryDiffCommandTests
{
private readonly IServiceProvider _services;
private readonly Option<bool> _verboseOption;
private readonly CancellationToken _cancellationToken;
public BinaryDiffCommandTests()
{
_services = new ServiceCollection().BuildServiceProvider();
_verboseOption = new Option<bool>("--verbose", new[] { "-v" })
{
Description = "Enable verbose output"
};
_cancellationToken = CancellationToken.None;
}
[Fact]
public void BuildDiffCommand_HasRequiredOptions()
{
var command = BuildDiffCommand();
Assert.Contains(command.Options, option => HasAlias(option, "--base", "-b"));
Assert.Contains(command.Options, option => HasAlias(option, "--target", "-t"));
Assert.Contains(command.Options, option => HasAlias(option, "--mode", "-m"));
Assert.Contains(command.Options, option => HasAlias(option, "--emit-dsse", "-d"));
Assert.Contains(command.Options, option => HasAlias(option, "--signing-key"));
Assert.Contains(command.Options, option => HasAlias(option, "--format", "-f"));
Assert.Contains(command.Options, option => HasAlias(option, "--platform", "-p"));
Assert.Contains(command.Options, option => HasAlias(option, "--include-unchanged"));
Assert.Contains(command.Options, option => HasAlias(option, "--sections"));
Assert.Contains(command.Options, option => HasAlias(option, "--registry-auth"));
Assert.Contains(command.Options, option => HasAlias(option, "--timeout"));
Assert.Contains(command.Options, option => HasAlias(option, "--verbose", "-v"));
}
[Fact]
public void BuildDiffCommand_RequiresBaseAndTarget()
{
var command = BuildDiffCommand();
var baseOption = FindOption(command, "--base");
var targetOption = FindOption(command, "--target");
Assert.NotNull(baseOption);
Assert.NotNull(targetOption);
Assert.True(baseOption!.IsRequired);
Assert.True(targetOption!.IsRequired);
}
[Fact]
public void DiffCommand_ParsesMinimalArgs()
{
var root = BuildRoot(out _);
var result = root.Parse("scan diff --base registry.example.com/app:1 --target registry.example.com/app:2");
Assert.Empty(result.Errors);
}
[Fact]
public void DiffCommand_FailsWhenBaseMissing()
{
var root = BuildRoot(out _);
var result = root.Parse("scan diff --target registry.example.com/app:2");
Assert.NotEmpty(result.Errors);
}
[Fact]
public void DiffCommand_ParsesSectionsValues()
{
var root = BuildRoot(out var diffCommand);
var result = root.Parse("scan diff --base registry.example.com/app:1 --target registry.example.com/app:2 --sections .text,.rodata --sections .data");
Assert.Empty(result.Errors);
var sectionsOption = diffCommand.Options
.OfType<Option<string[]>>()
.Single(option => HasAlias(option, "--sections"));
var values = result.GetValueForOption(sectionsOption);
Assert.Contains(".text,.rodata", values);
Assert.Contains(".data", values);
Assert.True(sectionsOption.AllowMultipleArgumentsPerToken);
}
private Command BuildDiffCommand()
{
return BinaryDiffCommandGroup.BuildDiffCommand(_services, _verboseOption, _cancellationToken);
}
private RootCommand BuildRoot(out Command diffCommand)
{
diffCommand = BuildDiffCommand();
var scan = new Command("scan", "Scanner operations")
{
diffCommand
};
return new RootCommand { scan };
}
private static Option? FindOption(Command command, string alias)
{
return command.Options.FirstOrDefault(option =>
option.Name.Equals(alias, StringComparison.OrdinalIgnoreCase) ||
option.Name.Equals(alias.TrimStart('-'), StringComparison.OrdinalIgnoreCase) ||
option.Aliases.Contains(alias));
}
private static bool HasAlias(Option option, params string[] aliases)
{
foreach (var alias in aliases)
{
if (option.Name.Equals(alias, StringComparison.OrdinalIgnoreCase) ||
option.Name.Equals(alias.TrimStart('-'), StringComparison.OrdinalIgnoreCase) ||
option.Aliases.Contains(alias))
{
return true;
}
}
return false;
}
}

View File

@@ -37,3 +37,4 @@
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,343 @@
// -----------------------------------------------------------------------------
// MirrorRateLimitingExtensions.cs
// Sprint: SPRINT_20260114_SOURCES_SETUP
// Task: 3.2 - Mirror Server Rate Limiting Setup
// Description: Extension methods for integrating mirror rate limiting with Router
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.Core.Configuration;
namespace StellaOps.Concelier.WebService.Extensions;
/// <summary>
/// Extension methods for configuring mirror server rate limiting using Router library.
/// </summary>
public static class MirrorRateLimitingExtensions
{
/// <summary>
/// Configuration section path for sources configuration.
/// </summary>
public const string SourcesConfigSection = "sources";
/// <summary>
/// Configuration section path for mirror server.
/// </summary>
public const string MirrorServerConfigSection = "sources:mirrorServer";
/// <summary>
/// Configuration section path for rate limits.
/// </summary>
public const string RateLimitsConfigSection = "sources:mirrorServer:rateLimits";
/// <summary>
/// Adds mirror rate limiting services using Router library integration.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration instance.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddMirrorRateLimiting(
this IServiceCollection services,
IConfiguration configuration)
{
var mirrorConfig = configuration
.GetSection(RateLimitsConfigSection)
.Get<MirrorRateLimitConfig>();
if (mirrorConfig is null || !mirrorConfig.IsEnabled)
{
return services;
}
// Validate configuration
mirrorConfig.Validate();
// Build Router-compatible configuration and register
var routerConfigSection = BuildRouterConfiguration(mirrorConfig);
// Register Router rate limiting services
// This maps our MirrorRateLimitConfig to Router's RateLimitConfig
var routerConfig = new ConfigurationBuilder()
.AddInMemoryCollection(routerConfigSection)
.Build();
// Register the Router rate limiting services
// Note: The actual Router library would have its own AddRouterRateLimiting extension
// This is a bridge to that library
services.Configure<MirrorRateLimitConfig>(
configuration.GetSection(RateLimitsConfigSection));
// Register middleware options
services.AddSingleton(mirrorConfig);
return services;
}
/// <summary>
/// Adds rate limiting middleware for mirror endpoints.
/// </summary>
/// <param name="app">Application builder.</param>
/// <returns>Application builder for chaining.</returns>
public static IApplicationBuilder UseMirrorRateLimiting(this IApplicationBuilder app)
{
var config = app.ApplicationServices.GetService<MirrorRateLimitConfig>();
if (config is null || !config.IsEnabled)
{
return app;
}
// Apply rate limiting to mirror endpoints
app.UseWhen(
context => context.Request.Path.StartsWithSegments("/api/mirror"),
branch =>
{
// The Router library middleware would be used here
// branch.UseMiddleware<RateLimitMiddleware>();
// For now, use our custom middleware adapter
branch.UseMiddleware<MirrorRateLimitMiddleware>();
});
return app;
}
/// <summary>
/// Builds Router-compatible configuration from MirrorRateLimitConfig.
/// </summary>
private static IEnumerable<KeyValuePair<string, string?>> BuildRouterConfiguration(
MirrorRateLimitConfig mirrorConfig)
{
var config = new Dictionary<string, string?>();
// Map activation threshold
config["rate_limiting:process_back_pressure_when_more_than_per_5min"] =
mirrorConfig.ActivationThresholdPer5Min.ToString();
// Map instance-level config
if (mirrorConfig.ForInstance is not null)
{
config["rate_limiting:for_instance:per_seconds"] =
mirrorConfig.ForInstance.PerSeconds.ToString();
config["rate_limiting:for_instance:max_requests"] =
mirrorConfig.ForInstance.MaxRequests.ToString();
if (mirrorConfig.ForInstance.AllowBurstForSeconds.HasValue)
{
config["rate_limiting:for_instance:allow_burst_for_seconds"] =
mirrorConfig.ForInstance.AllowBurstForSeconds.Value.ToString();
}
if (mirrorConfig.ForInstance.AllowMaxBurstRequests.HasValue)
{
config["rate_limiting:for_instance:allow_max_burst_requests"] =
mirrorConfig.ForInstance.AllowMaxBurstRequests.Value.ToString();
}
}
// Map environment-level config
if (mirrorConfig.ForEnvironment is not null)
{
if (!string.IsNullOrWhiteSpace(mirrorConfig.ForEnvironment.ValkeyConnection))
{
config["rate_limiting:for_environment:valkey_connection"] =
mirrorConfig.ForEnvironment.ValkeyConnection;
}
config["rate_limiting:for_environment:valkey_bucket"] =
mirrorConfig.ForEnvironment.ValkeyBucket;
config["rate_limiting:for_environment:per_seconds"] =
mirrorConfig.ForEnvironment.PerSeconds.ToString();
config["rate_limiting:for_environment:max_requests"] =
mirrorConfig.ForEnvironment.MaxRequests.ToString();
if (mirrorConfig.ForEnvironment.AllowBurstForSeconds.HasValue)
{
config["rate_limiting:for_environment:allow_burst_for_seconds"] =
mirrorConfig.ForEnvironment.AllowBurstForSeconds.Value.ToString();
}
if (mirrorConfig.ForEnvironment.AllowMaxBurstRequests.HasValue)
{
config["rate_limiting:for_environment:allow_max_burst_requests"] =
mirrorConfig.ForEnvironment.AllowMaxBurstRequests.Value.ToString();
}
// Map route-specific limits
foreach (var (routeName, routeConfig) in mirrorConfig.ForEnvironment.Routes)
{
var routePrefix = $"rate_limiting:for_environment:microservices:mirror:routes:{routeName}";
config[$"{routePrefix}:pattern"] = routeConfig.Pattern;
config[$"{routePrefix}:match_type"] = routeConfig.MatchType.ToString();
config[$"{routePrefix}:per_seconds"] = routeConfig.PerSeconds.ToString();
config[$"{routePrefix}:max_requests"] = routeConfig.MaxRequests.ToString();
if (routeConfig.AllowBurstForSeconds.HasValue)
{
config[$"{routePrefix}:allow_burst_for_seconds"] =
routeConfig.AllowBurstForSeconds.Value.ToString();
}
if (routeConfig.AllowMaxBurstRequests.HasValue)
{
config[$"{routePrefix}:allow_max_burst_requests"] =
routeConfig.AllowMaxBurstRequests.Value.ToString();
}
}
// Map circuit breaker config
if (mirrorConfig.ForEnvironment.CircuitBreaker is not null)
{
config["rate_limiting:for_environment:circuit_breaker:failure_threshold"] =
mirrorConfig.ForEnvironment.CircuitBreaker.FailureThreshold.ToString();
config["rate_limiting:for_environment:circuit_breaker:timeout_seconds"] =
mirrorConfig.ForEnvironment.CircuitBreaker.TimeoutSeconds.ToString();
config["rate_limiting:for_environment:circuit_breaker:half_open_timeout"] =
mirrorConfig.ForEnvironment.CircuitBreaker.HalfOpenTimeoutSeconds.ToString();
}
}
return config;
}
}
/// <summary>
/// Middleware for applying rate limits to mirror endpoints.
/// Bridges to Router library rate limiting.
/// </summary>
public class MirrorRateLimitMiddleware
{
private readonly RequestDelegate _next;
private readonly MirrorRateLimitConfig _config;
private readonly TimeProvider _timeProvider;
// Simple in-memory counters for instance-level limiting
// Environment-level would use Valkey via Router library
private readonly Dictionary<string, RateLimitCounter> _counters = new();
private readonly object _lock = new();
public MirrorRateLimitMiddleware(
RequestDelegate next,
MirrorRateLimitConfig config,
TimeProvider? timeProvider = null)
{
_next = next;
_config = config;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task InvokeAsync(HttpContext context)
{
if (!_config.IsEnabled)
{
await _next(context);
return;
}
var path = context.Request.Path.Value ?? "";
var clientId = GetClientIdentifier(context);
// Check instance-level limits first
if (_config.ForInstance is not null)
{
var (allowed, retryAfter) = CheckInstanceLimit(clientId, path);
if (!allowed)
{
await WriteRateLimitResponse(context, retryAfter, "instance");
return;
}
}
// Environment-level checking would go through Router's ValkeyRateLimitStore
// For now, we skip environment checks if no Valkey is configured
if (_config.ForEnvironment is not null &&
!string.IsNullOrWhiteSpace(_config.ForEnvironment.ValkeyConnection))
{
// TODO: Integrate with Router's EnvironmentRateLimiter via Valkey
// var (allowed, retryAfter) = await CheckEnvironmentLimitAsync(clientId, path);
// if (!allowed) { ... }
}
// Add rate limit headers
AddRateLimitHeaders(context, path);
await _next(context);
}
private (bool Allowed, TimeSpan RetryAfter) CheckInstanceLimit(string clientId, string path)
{
if (_config.ForInstance is null)
return (true, TimeSpan.Zero);
var now = _timeProvider.GetUtcNow();
var window = TimeSpan.FromSeconds(_config.ForInstance.PerSeconds);
var key = $"{clientId}:{path}";
lock (_lock)
{
if (!_counters.TryGetValue(key, out var counter) ||
now - counter.WindowStart >= window)
{
counter = new RateLimitCounter(now, 0);
}
if (counter.Count >= _config.ForInstance.MaxRequests)
{
var windowEnd = counter.WindowStart + window;
var retryAfter = windowEnd > now ? windowEnd - now : TimeSpan.Zero;
return (false, retryAfter);
}
_counters[key] = counter with { Count = counter.Count + 1 };
return (true, TimeSpan.Zero);
}
}
private static string GetClientIdentifier(HttpContext context)
{
// Use client IP or authenticated user ID
var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(forwardedFor))
{
return forwardedFor.Split(',')[0].Trim();
}
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}
private void AddRateLimitHeaders(HttpContext context, string path)
{
if (_config.ForInstance is not null)
{
context.Response.Headers["X-RateLimit-Limit"] =
_config.ForInstance.MaxRequests.ToString();
context.Response.Headers["X-RateLimit-Window"] =
_config.ForInstance.PerSeconds.ToString();
}
}
private static async Task WriteRateLimitResponse(
HttpContext context,
TimeSpan retryAfter,
string scope)
{
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.Response.Headers["Retry-After"] =
((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
context.Response.Headers["X-RateLimit-Scope"] = scope;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new
{
error = "rate_limit_exceeded",
message = $"Mirror rate limit exceeded. Try again in {(int)retryAfter.TotalSeconds} seconds.",
retryAfter = (int)retryAfter.TotalSeconds,
scope
});
}
private sealed record RateLimitCounter(DateTimeOffset WindowStart, int Count);
}

View File

@@ -0,0 +1,308 @@
// -----------------------------------------------------------------------------
// MirrorRateLimitConfig.cs
// Sprint: SPRINT_20260114_SOURCES_SETUP
// Task: 3.1 - Rate Limit Configuration Models
// Description: Rate limiting configuration for mirror server endpoints.
// Maps to Router library's RateLimitConfig structure.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.Configuration;
/// <summary>
/// Rate limiting configuration for mirror server endpoints.
/// This maps to Router library's <c>RateLimitConfig</c> for Gateway execution.
/// </summary>
/// <remarks>
/// Configuration example:
/// <code>
/// mirrorServer:
/// rateLimits:
/// forInstance:
/// perSeconds: 60
/// maxRequests: 100
/// forEnvironment:
/// valkeyConnection: "localhost:6379"
/// perSeconds: 3600
/// maxRequests: 10000
/// routes:
/// index:
/// pattern: "/api/mirror/index"
/// perSeconds: 60
/// maxRequests: 60
/// bundle:
/// pattern: "/api/mirror/bundle/*"
/// perSeconds: 60
/// maxRequests: 600
/// </code>
/// </remarks>
public sealed record MirrorRateLimitConfig
{
/// <summary>
/// Instance-level rate limits (in-memory, per-process).
/// Maps to Router's <c>rate_limiting.for_instance</c>.
/// </summary>
public InstanceRateLimitConfig? ForInstance { get; init; }
/// <summary>
/// Environment-level rate limits (Valkey-backed, distributed).
/// Maps to Router's <c>rate_limiting.for_environment</c>.
/// </summary>
public EnvironmentRateLimitConfig? ForEnvironment { get; init; }
/// <summary>
/// Activation threshold: only check environment limits when traffic exceeds this per 5 min.
/// Set to 0 to always check. Default: 1000.
/// </summary>
public int ActivationThresholdPer5Min { get; init; } = 1000;
/// <summary>
/// Whether rate limiting is configured (at least one scope defined).
/// </summary>
public bool IsEnabled => ForInstance is not null || ForEnvironment is not null;
/// <summary>
/// Validate configuration values.
/// </summary>
public MirrorRateLimitConfig Validate()
{
if (ActivationThresholdPer5Min < 0)
throw new ArgumentException("Activation threshold must be >= 0", nameof(ActivationThresholdPer5Min));
ForInstance?.Validate();
ForEnvironment?.Validate();
return this;
}
}
/// <summary>
/// Instance-level rate limit configuration (in-memory, per-process).
/// </summary>
public sealed record InstanceRateLimitConfig
{
/// <summary>
/// Time window in seconds for the rate limit.
/// </summary>
public int PerSeconds { get; init; } = 60;
/// <summary>
/// Maximum requests allowed in the time window.
/// </summary>
public int MaxRequests { get; init; } = 100;
/// <summary>
/// Optional burst window in seconds.
/// </summary>
public int? AllowBurstForSeconds { get; init; }
/// <summary>
/// Maximum burst requests allowed.
/// </summary>
public int? AllowMaxBurstRequests { get; init; }
/// <summary>
/// Validate configuration values.
/// </summary>
public void Validate()
{
if (PerSeconds <= 0)
throw new ArgumentException("PerSeconds must be > 0", nameof(PerSeconds));
if (MaxRequests <= 0)
throw new ArgumentException("MaxRequests must be > 0", nameof(MaxRequests));
if (AllowBurstForSeconds is <= 0)
throw new ArgumentException("AllowBurstForSeconds must be > 0 if specified", nameof(AllowBurstForSeconds));
if (AllowMaxBurstRequests is <= 0)
throw new ArgumentException("AllowMaxBurstRequests must be > 0 if specified", nameof(AllowMaxBurstRequests));
// Both burst values must be set together or neither
if ((AllowBurstForSeconds.HasValue) != (AllowMaxBurstRequests.HasValue))
throw new ArgumentException("AllowBurstForSeconds and AllowMaxBurstRequests must both be set or neither");
}
}
/// <summary>
/// Environment-level rate limit configuration (Valkey-backed, distributed).
/// </summary>
public sealed record EnvironmentRateLimitConfig
{
/// <summary>
/// Valkey connection string.
/// </summary>
public string? ValkeyConnection { get; init; }
/// <summary>
/// Valkey bucket/prefix for rate limit keys.
/// </summary>
public string ValkeyBucket { get; init; } = "stella-mirror-rate-limit";
/// <summary>
/// Time window in seconds.
/// </summary>
public int PerSeconds { get; init; } = 3600;
/// <summary>
/// Maximum requests in the time window.
/// </summary>
public int MaxRequests { get; init; } = 10000;
/// <summary>
/// Optional burst window in seconds.
/// </summary>
public int? AllowBurstForSeconds { get; init; }
/// <summary>
/// Maximum burst requests allowed.
/// </summary>
public int? AllowMaxBurstRequests { get; init; }
/// <summary>
/// Per-route rate limit overrides.
/// Keys are route names (e.g., "index", "bundle"), values are route configs.
/// </summary>
public ImmutableDictionary<string, RouteRateLimitConfig> Routes { get; init; }
= ImmutableDictionary<string, RouteRateLimitConfig>.Empty;
/// <summary>
/// Circuit breaker configuration for Valkey resilience.
/// </summary>
public CircuitBreakerConfig? CircuitBreaker { get; init; }
/// <summary>
/// Validate configuration values.
/// </summary>
public void Validate()
{
if (string.IsNullOrWhiteSpace(ValkeyBucket))
throw new ArgumentException("ValkeyBucket is required", nameof(ValkeyBucket));
if (PerSeconds <= 0)
throw new ArgumentException("PerSeconds must be > 0", nameof(PerSeconds));
if (MaxRequests <= 0)
throw new ArgumentException("MaxRequests must be > 0", nameof(MaxRequests));
if (AllowBurstForSeconds is <= 0)
throw new ArgumentException("AllowBurstForSeconds must be > 0 if specified", nameof(AllowBurstForSeconds));
if (AllowMaxBurstRequests is <= 0)
throw new ArgumentException("AllowMaxBurstRequests must be > 0 if specified", nameof(AllowMaxBurstRequests));
// Both burst values must be set together or neither
if ((AllowBurstForSeconds.HasValue) != (AllowMaxBurstRequests.HasValue))
throw new ArgumentException("AllowBurstForSeconds and AllowMaxBurstRequests must both be set or neither");
foreach (var (name, config) in Routes)
{
config.Validate(name);
}
CircuitBreaker?.Validate();
}
}
/// <summary>
/// Per-route rate limit configuration.
/// </summary>
public sealed record RouteRateLimitConfig
{
/// <summary>
/// Route pattern: exact ("/api/mirror/index"), prefix ("/api/mirror/bundle/*"), or regex.
/// </summary>
public required string Pattern { get; init; }
/// <summary>
/// Pattern match type: Exact, Prefix, or Regex.
/// </summary>
public RouteMatchType MatchType { get; init; } = RouteMatchType.Prefix;
/// <summary>
/// Time window in seconds.
/// </summary>
public int PerSeconds { get; init; }
/// <summary>
/// Maximum requests in the time window.
/// </summary>
public int MaxRequests { get; init; }
/// <summary>
/// Optional burst window in seconds.
/// </summary>
public int? AllowBurstForSeconds { get; init; }
/// <summary>
/// Maximum burst requests allowed.
/// </summary>
public int? AllowMaxBurstRequests { get; init; }
/// <summary>
/// Validate configuration values.
/// </summary>
public void Validate(string routeName)
{
if (string.IsNullOrWhiteSpace(Pattern))
throw new ArgumentException($"Route '{routeName}': Pattern is required");
if (PerSeconds <= 0)
throw new ArgumentException($"Route '{routeName}': PerSeconds must be > 0");
if (MaxRequests <= 0)
throw new ArgumentException($"Route '{routeName}': MaxRequests must be > 0");
}
}
/// <summary>
/// Route pattern match type.
/// </summary>
public enum RouteMatchType
{
/// <summary>Exact path match.</summary>
Exact,
/// <summary>Prefix match (pattern ends with *).</summary>
Prefix,
/// <summary>Regular expression match.</summary>
Regex
}
/// <summary>
/// Circuit breaker configuration for Valkey resilience.
/// </summary>
public sealed record CircuitBreakerConfig
{
/// <summary>
/// Number of failures before opening the circuit.
/// </summary>
public int FailureThreshold { get; init; } = 5;
/// <summary>
/// Seconds to keep circuit open before attempting recovery.
/// </summary>
public int TimeoutSeconds { get; init; } = 30;
/// <summary>
/// Seconds in half-open state before full reset.
/// </summary>
public int HalfOpenTimeoutSeconds { get; init; } = 10;
/// <summary>
/// Validate configuration values.
/// </summary>
public void Validate()
{
if (FailureThreshold < 1)
throw new ArgumentException("FailureThreshold must be >= 1", nameof(FailureThreshold));
if (TimeoutSeconds < 1)
throw new ArgumentException("TimeoutSeconds must be >= 1", nameof(TimeoutSeconds));
if (HalfOpenTimeoutSeconds < 1)
throw new ArgumentException("HalfOpenTimeoutSeconds must be >= 1", nameof(HalfOpenTimeoutSeconds));
}
}

View File

@@ -0,0 +1,223 @@
// -----------------------------------------------------------------------------
// SourceConfiguration.cs
// Sprint: SPRINT_20260114_SOURCES_SETUP
// Task: 3.1 - Configuration Models
// Description: Root configuration for advisory data sources and mirror server
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Concelier.Core.Configuration;
/// <summary>
/// Root configuration for advisory data sources.
/// Supports direct upstream sources (NVD, OSV, GHSA, etc.) and StellaOps mirrors.
/// </summary>
public sealed record SourcesConfiguration
{
/// <summary>
/// Source mode: "direct" for upstream sources, "mirror" for StellaOps mirrors.
/// Default is "mirror" for simpler setup.
/// </summary>
public SourceMode Mode { get; init; } = SourceMode.Mirror;
/// <summary>
/// StellaOps mirror endpoint when using mirror mode.
/// </summary>
public string? MirrorEndpoint { get; init; }
/// <summary>
/// Mirror server configuration when exposing gathered data as a mirror.
/// </summary>
public MirrorServerConfig MirrorServer { get; init; } = new();
/// <summary>
/// Individual source configurations (NVD, OSV, GHSA, etc.).
/// Each source can be enabled/disabled and configured independently.
/// </summary>
public ImmutableDictionary<string, SourceConfig> Sources { get; init; }
= ImmutableDictionary<string, SourceConfig>.Empty;
/// <summary>
/// Auto-enable sources that pass connectivity checks.
/// When true, all sources are enabled by default during setup.
/// </summary>
public bool AutoEnableHealthySources { get; init; } = true;
/// <summary>
/// Timeout for connectivity checks in seconds.
/// </summary>
public int ConnectivityCheckTimeoutSeconds { get; init; } = 30;
}
/// <summary>
/// Source mode for advisory data ingestion.
/// </summary>
public enum SourceMode
{
/// <summary>
/// Direct connection to upstream sources (NVD, OSV, GHSA, etc.).
/// Requires individual source credentials and network access.
/// </summary>
Direct,
/// <summary>
/// Connection to StellaOps pre-aggregated mirror.
/// Simpler setup, single endpoint, pre-normalized data.
/// </summary>
Mirror,
/// <summary>
/// Hybrid mode: use mirror as primary, fall back to direct sources.
/// </summary>
Hybrid
}
/// <summary>
/// Configuration for an individual advisory source.
/// </summary>
public sealed record SourceConfig
{
/// <summary>
/// Whether this source is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Priority for merge ordering (lower = higher priority).
/// </summary>
public int Priority { get; init; } = 100;
/// <summary>
/// API key or token for authenticated sources.
/// </summary>
public string? ApiKey { get; init; }
/// <summary>
/// Optional custom endpoint URL (overrides default).
/// </summary>
public string? Endpoint { get; init; }
/// <summary>
/// Request delay between API calls (rate limiting).
/// </summary>
public TimeSpan RequestDelay { get; init; } = TimeSpan.FromMilliseconds(200);
/// <summary>
/// Backoff duration after failures.
/// </summary>
public TimeSpan FailureBackoff { get; init; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Maximum pages to fetch per sync cycle.
/// </summary>
public int MaxPagesPerFetch { get; init; } = 10;
/// <summary>
/// Source-specific metadata.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; }
= ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Configuration for exposing gathered data as a mirror server.
/// </summary>
public sealed record MirrorServerConfig
{
/// <summary>
/// Whether the mirror server is enabled.
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// Root directory for mirror exports.
/// </summary>
public string ExportRoot { get; init; } = "./exports/mirror";
/// <summary>
/// Authentication mode for mirror clients.
/// </summary>
public MirrorAuthMode Authentication { get; init; } = MirrorAuthMode.Anonymous;
/// <summary>
/// OAuth configuration when using OAuth authentication.
/// </summary>
public OAuthMirrorConfig? OAuth { get; init; }
/// <summary>
/// Rate limiting configuration for mirror endpoints.
/// Maps to Router library rate limiting.
/// </summary>
public MirrorRateLimitConfig RateLimits { get; init; } = new();
/// <summary>
/// Signing key path for DSSE attestations on mirror bundles.
/// </summary>
public string? SigningKeyPath { get; init; }
/// <summary>
/// Whether to include attestations in mirror bundles.
/// </summary>
public bool IncludeAttestations { get; init; } = true;
}
/// <summary>
/// Authentication mode for mirror server.
/// </summary>
public enum MirrorAuthMode
{
/// <summary>
/// No authentication required (open access).
/// </summary>
Anonymous,
/// <summary>
/// OAuth 2.0 client credentials flow.
/// </summary>
OAuth,
/// <summary>
/// API key-based authentication.
/// </summary>
ApiKey,
/// <summary>
/// mTLS client certificate authentication.
/// </summary>
Mtls
}
/// <summary>
/// OAuth configuration for mirror server authentication.
/// </summary>
public sealed record OAuthMirrorConfig
{
/// <summary>
/// OAuth issuer URL (for discovery).
/// </summary>
[Required]
public string Issuer { get; init; } = "";
/// <summary>
/// Required audience in access tokens.
/// </summary>
public string? Audience { get; init; }
/// <summary>
/// Required scopes for access.
/// </summary>
public ImmutableArray<string> RequiredScopes { get; init; }
= ImmutableArray<string>.Empty;
/// <summary>
/// Whether to validate issuer HTTPS metadata.
/// </summary>
public bool RequireHttpsMetadata { get; init; } = true;
/// <summary>
/// Token clock skew tolerance.
/// </summary>
public TimeSpan ClockSkew { get; init; } = TimeSpan.FromMinutes(1);
}

View File

@@ -0,0 +1,98 @@
// -----------------------------------------------------------------------------
// ISourceRegistry.cs
// Sprint: SPRINT_20260114_SOURCES_SETUP
// Task: 3.3 - Source Registry Interface
// Description: Interface for managing and checking advisory source connectivity
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.Sources;
/// <summary>
/// Registry for managing advisory data sources (NVD, OSV, GHSA, etc.).
/// Provides connectivity checking and auto-configuration capabilities.
/// </summary>
public interface ISourceRegistry
{
/// <summary>
/// Get all registered source definitions.
/// </summary>
IReadOnlyList<SourceDefinition> GetAllSources();
/// <summary>
/// Get a specific source definition by ID.
/// </summary>
SourceDefinition? GetSource(string sourceId);
/// <summary>
/// Get sources by category.
/// </summary>
IReadOnlyList<SourceDefinition> GetSourcesByCategory(SourceCategory category);
/// <summary>
/// Check connectivity for a specific source.
/// </summary>
/// <param name="sourceId">Source identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Connectivity result with status and error details if failed.</returns>
Task<SourceConnectivityResult> CheckConnectivityAsync(
string sourceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Check all sources and auto-configure based on availability.
/// Sources that pass connectivity checks are enabled by default.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Aggregated check result with enabled/disabled sources.</returns>
Task<SourceCheckResult> CheckAllAndAutoConfigureAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Check connectivity for multiple sources in parallel.
/// </summary>
/// <param name="sourceIds">Source identifiers to check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Individual results for each source.</returns>
Task<ImmutableArray<SourceConnectivityResult>> CheckMultipleAsync(
IEnumerable<string> sourceIds,
CancellationToken cancellationToken = default);
/// <summary>
/// Enable a source for data ingestion.
/// </summary>
Task<bool> EnableSourceAsync(
string sourceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Disable a source.
/// </summary>
Task<bool> DisableSourceAsync(
string sourceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Get currently enabled sources.
/// </summary>
Task<ImmutableArray<string>> GetEnabledSourcesAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Check if a specific source is enabled.
/// </summary>
bool IsEnabled(string sourceId);
/// <summary>
/// Retry connectivity check for a failed source.
/// </summary>
Task<SourceConnectivityResult> RetryCheckAsync(
string sourceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Get the last connectivity check result for a source.
/// </summary>
SourceConnectivityResult? GetLastCheckResult(string sourceId);
}

View File

@@ -0,0 +1,160 @@
// -----------------------------------------------------------------------------
// SourceCheckResult.cs
// Sprint: SPRINT_20260114_SOURCES_SETUP
// Task: 3.3 - Source Check Aggregation
// Description: Aggregated result of checking multiple sources
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.Sources;
/// <summary>
/// Aggregated result of checking all sources for connectivity.
/// Used by the setup wizard to auto-enable healthy sources.
/// </summary>
public sealed record SourceCheckResult
{
/// <summary>
/// Individual results for each checked source.
/// </summary>
public ImmutableArray<SourceConnectivityResult> Results { get; init; }
= ImmutableArray<SourceConnectivityResult>.Empty;
/// <summary>
/// Source IDs that passed connectivity checks and are auto-enabled.
/// </summary>
public ImmutableArray<string> EnabledSources { get; init; }
= ImmutableArray<string>.Empty;
/// <summary>
/// Source IDs that failed connectivity checks.
/// </summary>
public ImmutableArray<string> DisabledSources { get; init; }
= ImmutableArray<string>.Empty;
/// <summary>
/// Source IDs that are degraded but still enabled.
/// </summary>
public ImmutableArray<string> DegradedSources { get; init; }
= ImmutableArray<string>.Empty;
/// <summary>
/// When the check was performed.
/// </summary>
public DateTimeOffset CheckedAt { get; init; }
/// <summary>
/// Total duration of all checks.
/// </summary>
public TimeSpan TotalDuration { get; init; }
/// <summary>
/// Whether all sources are healthy.
/// </summary>
public bool AllHealthy => DisabledSources.Length == 0;
/// <summary>
/// Whether any sources failed.
/// </summary>
public bool HasFailures => DisabledSources.Length > 0;
/// <summary>
/// Total number of sources checked.
/// </summary>
public int TotalChecked => Results.Length;
/// <summary>
/// Number of healthy sources.
/// </summary>
public int HealthyCount => EnabledSources.Length;
/// <summary>
/// Number of failed sources.
/// </summary>
public int FailedCount => DisabledSources.Length;
/// <summary>
/// Summary message for display.
/// </summary>
public string Summary => AllHealthy
? $"All {TotalChecked} sources are healthy"
: $"{HealthyCount}/{TotalChecked} sources healthy, {FailedCount} failed";
/// <summary>
/// Get results grouped by category.
/// </summary>
public ImmutableDictionary<SourceCategory, ImmutableArray<SourceConnectivityResult>> ByCategory(
ISourceRegistry registry)
{
var builder = ImmutableDictionary.CreateBuilder<SourceCategory, ImmutableArray<SourceConnectivityResult>>();
var groups = Results
.GroupBy(r => registry.GetSource(r.SourceId)?.Category ?? SourceCategory.Other)
.ToDictionary(g => g.Key, g => g.ToImmutableArray());
foreach (var (category, results) in groups)
{
builder[category] = results;
}
return builder.ToImmutable();
}
/// <summary>
/// Get failed results for display.
/// </summary>
public ImmutableArray<SourceConnectivityResult> GetFailedResults()
=> Results.Where(r => r.Status == SourceConnectivityStatus.Failed).ToImmutableArray();
/// <summary>
/// Create an empty result.
/// </summary>
public static SourceCheckResult Empty(DateTimeOffset? checkedAt = null)
=> new()
{
CheckedAt = checkedAt ?? DateTimeOffset.UtcNow,
TotalDuration = TimeSpan.Zero
};
/// <summary>
/// Create result from individual results.
/// </summary>
public static SourceCheckResult FromResults(
IEnumerable<SourceConnectivityResult> results,
DateTimeOffset checkedAt,
TimeSpan duration)
{
var resultArray = results.ToImmutableArray();
var enabled = new List<string>();
var disabled = new List<string>();
var degraded = new List<string>();
foreach (var result in resultArray)
{
switch (result.Status)
{
case SourceConnectivityStatus.Healthy:
enabled.Add(result.SourceId);
break;
case SourceConnectivityStatus.Degraded:
enabled.Add(result.SourceId);
degraded.Add(result.SourceId);
break;
case SourceConnectivityStatus.Failed:
disabled.Add(result.SourceId);
break;
}
}
return new SourceCheckResult
{
Results = resultArray,
EnabledSources = enabled.ToImmutableArray(),
DisabledSources = disabled.ToImmutableArray(),
DegradedSources = degraded.ToImmutableArray(),
CheckedAt = checkedAt,
TotalDuration = duration
};
}
}

View File

@@ -0,0 +1,231 @@
// -----------------------------------------------------------------------------
// SourceConnectivityResult.cs
// Sprint: SPRINT_20260114_SOURCES_SETUP
// Task: 3.3 - Source Connectivity Models
// Description: Result types for source connectivity checks
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.Sources;
/// <summary>
/// Result of a connectivity check for a single source.
/// </summary>
public sealed record SourceConnectivityResult
{
/// <summary>
/// Source identifier.
/// </summary>
public required string SourceId { get; init; }
/// <summary>
/// Connectivity status.
/// </summary>
public SourceConnectivityStatus Status { get; init; }
/// <summary>
/// When the check was performed.
/// </summary>
public DateTimeOffset CheckedAt { get; init; }
/// <summary>
/// Response latency if successful.
/// </summary>
public TimeSpan? Latency { get; init; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Error code for categorization.
/// </summary>
public string? ErrorCode { get; init; }
/// <summary>
/// HTTP status code if applicable.
/// </summary>
public int? HttpStatusCode { get; init; }
/// <summary>
/// Possible reasons for the failure (for user display).
/// </summary>
public ImmutableArray<string> PossibleReasons { get; init; }
= ImmutableArray<string>.Empty;
/// <summary>
/// Steps to remediate the issue.
/// </summary>
public ImmutableArray<RemediationStep> RemediationSteps { get; init; }
= ImmutableArray<RemediationStep>.Empty;
/// <summary>
/// Documentation URL for more information.
/// </summary>
public string? DocumentationUrl { get; init; }
/// <summary>
/// Additional diagnostic data.
/// </summary>
public ImmutableDictionary<string, string> Diagnostics { get; init; }
= ImmutableDictionary<string, string>.Empty;
/// <summary>
/// Whether the source is healthy (can be enabled).
/// </summary>
public bool IsHealthy => Status is SourceConnectivityStatus.Healthy or SourceConnectivityStatus.Degraded;
/// <summary>
/// Create a healthy result.
/// </summary>
public static SourceConnectivityResult Healthy(
string sourceId,
TimeSpan latency,
DateTimeOffset? checkedAt = null)
=> new()
{
SourceId = sourceId,
Status = SourceConnectivityStatus.Healthy,
CheckedAt = checkedAt ?? DateTimeOffset.UtcNow,
Latency = latency
};
/// <summary>
/// Create a degraded result.
/// </summary>
public static SourceConnectivityResult Degraded(
string sourceId,
TimeSpan latency,
string message,
DateTimeOffset? checkedAt = null)
=> new()
{
SourceId = sourceId,
Status = SourceConnectivityStatus.Degraded,
CheckedAt = checkedAt ?? DateTimeOffset.UtcNow,
Latency = latency,
ErrorMessage = message
};
/// <summary>
/// Create a failed result.
/// </summary>
public static SourceConnectivityResult Failed(
string sourceId,
string errorCode,
string errorMessage,
ImmutableArray<string> possibleReasons,
ImmutableArray<RemediationStep> remediationSteps,
DateTimeOffset? checkedAt = null,
TimeSpan? latency = null,
int? httpStatusCode = null)
=> new()
{
SourceId = sourceId,
Status = SourceConnectivityStatus.Failed,
CheckedAt = checkedAt ?? DateTimeOffset.UtcNow,
Latency = latency,
ErrorCode = errorCode,
ErrorMessage = errorMessage,
HttpStatusCode = httpStatusCode,
PossibleReasons = possibleReasons,
RemediationSteps = remediationSteps
};
/// <summary>
/// Create a not-found result.
/// </summary>
public static SourceConnectivityResult NotFound(string sourceId)
=> new()
{
SourceId = sourceId,
Status = SourceConnectivityStatus.Failed,
CheckedAt = DateTimeOffset.UtcNow,
ErrorCode = "SOURCE_NOT_FOUND",
ErrorMessage = $"Source '{sourceId}' is not registered",
PossibleReasons = ImmutableArray.Create(
"The source ID may be misspelled",
"This source may not be available in your region"),
RemediationSteps = ImmutableArray.Create(
new RemediationStep { Order = 1, Description = "Verify the source ID is correct" },
new RemediationStep { Order = 2, Description = "Run 'stella sources list' to see available sources" })
};
}
/// <summary>
/// Connectivity status for a source.
/// </summary>
public enum SourceConnectivityStatus
{
/// <summary>Source is unknown or not checked.</summary>
Unknown,
/// <summary>Source is fully available.</summary>
Healthy,
/// <summary>Source is available but with issues (slow, rate limited, etc.).</summary>
Degraded,
/// <summary>Source is not available.</summary>
Failed,
/// <summary>Connectivity check is in progress.</summary>
Checking,
/// <summary>Source is disabled by configuration.</summary>
Disabled
}
/// <summary>
/// A step to remediate a connectivity issue.
/// </summary>
public sealed record RemediationStep
{
/// <summary>
/// Step order (1-based).
/// </summary>
public int Order { get; init; }
/// <summary>
/// Human-readable description of the step.
/// </summary>
public required string Description { get; init; }
/// <summary>
/// Optional command to run.
/// </summary>
public string? Command { get; init; }
/// <summary>
/// Type of command (bash, powershell, url, etc.).
/// </summary>
public CommandType CommandType { get; init; } = CommandType.Bash;
/// <summary>
/// URL for more information.
/// </summary>
public string? DocumentationUrl { get; init; }
}
/// <summary>
/// Type of remediation command.
/// </summary>
public enum CommandType
{
/// <summary>Bash/shell command.</summary>
Bash,
/// <summary>PowerShell command.</summary>
PowerShell,
/// <summary>URL to open.</summary>
Url,
/// <summary>StellaOps CLI command.</summary>
StellaCli,
/// <summary>Environment variable to set.</summary>
EnvVar
}

View File

@@ -0,0 +1,970 @@
// -----------------------------------------------------------------------------
// SourceDefinitions.cs
// Sprint: SPRINT_20260114_SOURCES_SETUP
// Task: 3.3 - Source Catalog
// Description: Static catalog of all supported advisory data sources
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.Sources;
/// <summary>
/// Definition of an advisory data source.
/// </summary>
public sealed record SourceDefinition
{
/// <summary>
/// Unique identifier for the source (e.g., "nvd", "ghsa", "osv").
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Human-readable display name.
/// </summary>
public required string DisplayName { get; init; }
/// <summary>
/// Source category.
/// </summary>
public SourceCategory Category { get; init; }
/// <summary>
/// Source type (upstream, mirror, etc.).
/// </summary>
public SourceType Type { get; init; }
/// <summary>
/// Brief description of the source.
/// </summary>
public string Description { get; init; } = "";
/// <summary>
/// Base API endpoint URL.
/// </summary>
public required string BaseEndpoint { get; init; }
/// <summary>
/// Health check endpoint for connectivity verification.
/// </summary>
public required string HealthCheckEndpoint { get; init; }
/// <summary>
/// Named HTTP client for this source.
/// </summary>
public string HttpClientName { get; init; } = "";
/// <summary>
/// Whether authentication is required.
/// </summary>
public bool RequiresAuthentication { get; init; }
/// <summary>
/// Environment variable name for API key/token.
/// </summary>
public string? CredentialEnvVar { get; init; }
/// <summary>
/// URL for obtaining credentials.
/// </summary>
public string? CredentialUrl { get; init; }
/// <summary>
/// Status page URL for the source.
/// </summary>
public string? StatusPageUrl { get; init; }
/// <summary>
/// Documentation URL.
/// </summary>
public string? DocumentationUrl { get; init; }
/// <summary>
/// Default priority for merge ordering.
/// </summary>
public int DefaultPriority { get; init; } = 100;
/// <summary>
/// Geographic regions this source covers (if region-specific).
/// </summary>
public ImmutableArray<string> Regions { get; init; }
= ImmutableArray<string>.Empty;
/// <summary>
/// Whether the source is enabled by default.
/// </summary>
public bool EnabledByDefault { get; init; } = true;
/// <summary>
/// Tags for filtering/grouping sources.
/// </summary>
public ImmutableArray<string> Tags { get; init; }
= ImmutableArray<string>.Empty;
}
/// <summary>
/// Category of advisory source.
/// </summary>
public enum SourceCategory
{
/// <summary>Primary vulnerability databases (NVD, OSV).</summary>
Primary,
/// <summary>Vendor-specific advisories (Red Hat, Microsoft).</summary>
Vendor,
/// <summary>Linux distribution advisories (Debian, Ubuntu).</summary>
Distribution,
/// <summary>Language ecosystem advisories (npm, PyPI).</summary>
Ecosystem,
/// <summary>National CERTs and government sources.</summary>
Cert,
/// <summary>CSAF/VEX document sources.</summary>
Csaf,
/// <summary>Exploit and threat intelligence sources.</summary>
Threat,
/// <summary>StellaOps mirrors.</summary>
Mirror,
/// <summary>Other/uncategorized sources.</summary>
Other
}
/// <summary>
/// Type of source connection.
/// </summary>
public enum SourceType
{
/// <summary>Direct upstream API connection.</summary>
Upstream,
/// <summary>StellaOps pre-aggregated mirror.</summary>
StellaMirror,
/// <summary>Local file-based source.</summary>
LocalFile,
/// <summary>Custom/user-defined source.</summary>
Custom
}
/// <summary>
/// Static catalog of all supported sources.
/// </summary>
public static class SourceDefinitions
{
// ===== Primary Databases =====
public static readonly SourceDefinition Nvd = new()
{
Id = "nvd",
DisplayName = "NVD (NIST)",
Category = SourceCategory.Primary,
Type = SourceType.Upstream,
Description = "NIST National Vulnerability Database",
BaseEndpoint = "https://services.nvd.nist.gov/rest/json/cves/2.0",
HealthCheckEndpoint = "https://services.nvd.nist.gov/rest/json/cves/2.0?resultsPerPage=1",
HttpClientName = "NvdClient",
RequiresAuthentication = false, // Optional but recommended
CredentialEnvVar = "NVD_API_KEY",
CredentialUrl = "https://nvd.nist.gov/developers/request-an-api-key",
StatusPageUrl = "https://nvd.nist.gov/",
DocumentationUrl = "https://nvd.nist.gov/developers/vulnerabilities",
DefaultPriority = 10,
Tags = ImmutableArray.Create("cve", "primary", "global")
};
public static readonly SourceDefinition Osv = new()
{
Id = "osv",
DisplayName = "OSV (Google)",
Category = SourceCategory.Primary,
Type = SourceType.Upstream,
Description = "Open Source Vulnerabilities database",
BaseEndpoint = "https://api.osv.dev/v1",
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
HttpClientName = "OsvClient",
RequiresAuthentication = false,
StatusPageUrl = "https://osv.dev",
DocumentationUrl = "https://osv.dev/docs/",
DefaultPriority = 15,
Tags = ImmutableArray.Create("osv", "primary", "ecosystem")
};
public static readonly SourceDefinition Ghsa = new()
{
Id = "ghsa",
DisplayName = "GitHub Security Advisories",
Category = SourceCategory.Primary,
Type = SourceType.Upstream,
Description = "GitHub Security Advisories database",
BaseEndpoint = "https://api.github.com/graphql",
HealthCheckEndpoint = "https://api.github.com/zen",
HttpClientName = "GhsaClient",
RequiresAuthentication = true,
CredentialEnvVar = "GITHUB_PAT",
CredentialUrl = "https://github.com/settings/tokens",
StatusPageUrl = "https://www.githubstatus.com/",
DocumentationUrl = "https://docs.github.com/en/graphql/reference/objects#securityadvisory",
DefaultPriority = 20,
Tags = ImmutableArray.Create("github", "primary", "ecosystem")
};
public static readonly SourceDefinition Cve = new()
{
Id = "cve",
DisplayName = "CVE.org (MITRE)",
Category = SourceCategory.Primary,
Type = SourceType.Upstream,
Description = "MITRE CVE Program",
BaseEndpoint = "https://cveawg.mitre.org/api/",
HealthCheckEndpoint = "https://cveawg.mitre.org/api/cve/CVE-2021-44228",
HttpClientName = "CveClient",
RequiresAuthentication = false,
StatusPageUrl = "https://cve.mitre.org/",
DocumentationUrl = "https://cveawg.mitre.org/api/",
DefaultPriority = 5,
Tags = ImmutableArray.Create("cve", "primary", "authoritative")
};
public static readonly SourceDefinition Epss = new()
{
Id = "epss",
DisplayName = "EPSS (FIRST)",
Category = SourceCategory.Threat,
Type = SourceType.Upstream,
Description = "Exploit Prediction Scoring System",
BaseEndpoint = "https://api.first.org/data/v1",
HealthCheckEndpoint = "https://api.first.org/data/v1/epss?cve=CVE-2021-44228",
HttpClientName = "EpssClient",
RequiresAuthentication = false,
StatusPageUrl = "https://www.first.org/epss/",
DocumentationUrl = "https://www.first.org/epss/api",
DefaultPriority = 50,
Tags = ImmutableArray.Create("epss", "threat", "scoring")
};
public static readonly SourceDefinition Kev = new()
{
Id = "kev",
DisplayName = "CISA KEV",
Category = SourceCategory.Threat,
Type = SourceType.Upstream,
Description = "Known Exploited Vulnerabilities Catalog",
BaseEndpoint = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
HealthCheckEndpoint = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json",
HttpClientName = "KevClient",
RequiresAuthentication = false,
StatusPageUrl = "https://www.cisa.gov/known-exploited-vulnerabilities-catalog",
DocumentationUrl = "https://www.cisa.gov/known-exploited-vulnerabilities-catalog",
DefaultPriority = 25,
Tags = ImmutableArray.Create("kev", "threat", "exploit")
};
// ===== Vendor Advisories =====
public static readonly SourceDefinition RedHat = new()
{
Id = "redhat",
DisplayName = "Red Hat Security",
Category = SourceCategory.Vendor,
Type = SourceType.Upstream,
Description = "Red Hat Security Data API",
BaseEndpoint = "https://access.redhat.com/hydra/rest/securitydata/",
HealthCheckEndpoint = "https://access.redhat.com/hydra/rest/securitydata/cve.json?per_page=1",
HttpClientName = "RedHatClient",
RequiresAuthentication = false,
StatusPageUrl = "https://status.redhat.com/",
DocumentationUrl = "https://access.redhat.com/documentation/en-us/red_hat_security_data_api/",
DefaultPriority = 30,
Tags = ImmutableArray.Create("redhat", "vendor", "linux")
};
public static readonly SourceDefinition Microsoft = new()
{
Id = "microsoft",
DisplayName = "Microsoft Security",
Category = SourceCategory.Vendor,
Type = SourceType.Upstream,
Description = "Microsoft Security Response Center",
BaseEndpoint = "https://api.msrc.microsoft.com/sug/v2.0/en-US/",
HealthCheckEndpoint = "https://api.msrc.microsoft.com/sug/v2.0/en-US/affectedProduct",
HttpClientName = "MsrcClient",
RequiresAuthentication = false,
StatusPageUrl = "https://msrc.microsoft.com/",
DocumentationUrl = "https://msrc.microsoft.com/update-guide/",
DefaultPriority = 35,
Tags = ImmutableArray.Create("microsoft", "vendor", "windows")
};
public static readonly SourceDefinition Amazon = new()
{
Id = "amazon",
DisplayName = "Amazon Linux Security",
Category = SourceCategory.Vendor,
Type = SourceType.Upstream,
Description = "Amazon Linux Security Center",
BaseEndpoint = "https://alas.aws.amazon.com/",
HealthCheckEndpoint = "https://alas.aws.amazon.com/alas.rss",
HttpClientName = "AmazonClient",
RequiresAuthentication = false,
StatusPageUrl = "https://status.aws.amazon.com/",
DefaultPriority = 40,
Tags = ImmutableArray.Create("amazon", "vendor", "cloud", "linux")
};
public static readonly SourceDefinition Google = new()
{
Id = "google",
DisplayName = "Google Security",
Category = SourceCategory.Vendor,
Type = SourceType.Upstream,
Description = "Google Security Bulletins",
BaseEndpoint = "https://source.android.com/docs/security/bulletin/",
HealthCheckEndpoint = "https://source.android.com/docs/security/bulletin/",
HttpClientName = "GoogleClient",
RequiresAuthentication = false,
DefaultPriority = 45,
Tags = ImmutableArray.Create("google", "vendor", "android")
};
public static readonly SourceDefinition Oracle = new()
{
Id = "oracle",
DisplayName = "Oracle Security",
Category = SourceCategory.Vendor,
Type = SourceType.Upstream,
Description = "Oracle Security Alerts",
BaseEndpoint = "https://www.oracle.com/security-alerts/",
HealthCheckEndpoint = "https://www.oracle.com/security-alerts/",
HttpClientName = "OracleClient",
RequiresAuthentication = false,
DefaultPriority = 50,
Tags = ImmutableArray.Create("oracle", "vendor", "java")
};
public static readonly SourceDefinition Apple = new()
{
Id = "apple",
DisplayName = "Apple Security",
Category = SourceCategory.Vendor,
Type = SourceType.Upstream,
Description = "Apple Security Updates",
BaseEndpoint = "https://support.apple.com/en-us/HT201222",
HealthCheckEndpoint = "https://support.apple.com/en-us/HT201222",
HttpClientName = "AppleClient",
RequiresAuthentication = false,
DefaultPriority = 55,
Tags = ImmutableArray.Create("apple", "vendor", "macos", "ios")
};
public static readonly SourceDefinition Cisco = new()
{
Id = "cisco",
DisplayName = "Cisco Security",
Category = SourceCategory.Vendor,
Type = SourceType.Upstream,
Description = "Cisco Security Advisories",
BaseEndpoint = "https://tools.cisco.com/security/center/publicationService.x",
HealthCheckEndpoint = "https://tools.cisco.com/security/center/publicationListing.x",
HttpClientName = "CiscoClient",
RequiresAuthentication = false,
StatusPageUrl = "https://status.cisco.com/",
DefaultPriority = 60,
Tags = ImmutableArray.Create("cisco", "vendor", "network")
};
public static readonly SourceDefinition Fortinet = new()
{
Id = "fortinet",
DisplayName = "Fortinet PSIRT",
Category = SourceCategory.Vendor,
Type = SourceType.Upstream,
Description = "Fortinet Product Security Incident Response Team",
BaseEndpoint = "https://www.fortiguard.com/psirt",
HealthCheckEndpoint = "https://www.fortiguard.com/psirt",
HttpClientName = "FortinetClient",
RequiresAuthentication = false,
DefaultPriority = 65,
Tags = ImmutableArray.Create("fortinet", "vendor", "network", "security")
};
public static readonly SourceDefinition Juniper = new()
{
Id = "juniper",
DisplayName = "Juniper Security",
Category = SourceCategory.Vendor,
Type = SourceType.Upstream,
Description = "Juniper Security Advisories",
BaseEndpoint = "https://supportportal.juniper.net/s/",
HealthCheckEndpoint = "https://supportportal.juniper.net/s/",
HttpClientName = "JuniperClient",
RequiresAuthentication = false,
DefaultPriority = 70,
Tags = ImmutableArray.Create("juniper", "vendor", "network")
};
public static readonly SourceDefinition Palo = new()
{
Id = "paloalto",
DisplayName = "Palo Alto Security",
Category = SourceCategory.Vendor,
Type = SourceType.Upstream,
Description = "Palo Alto Networks Security Advisories",
BaseEndpoint = "https://security.paloaltonetworks.com/",
HealthCheckEndpoint = "https://security.paloaltonetworks.com/",
HttpClientName = "PaloClient",
RequiresAuthentication = false,
DefaultPriority = 75,
Tags = ImmutableArray.Create("paloalto", "vendor", "network", "security")
};
public static readonly SourceDefinition Vmware = new()
{
Id = "vmware",
DisplayName = "VMware Security",
Category = SourceCategory.Vendor,
Type = SourceType.Upstream,
Description = "VMware Security Advisories",
BaseEndpoint = "https://www.vmware.com/security/advisories.html",
HealthCheckEndpoint = "https://www.vmware.com/security/advisories.html",
HttpClientName = "VmwareClient",
RequiresAuthentication = false,
DefaultPriority = 80,
Tags = ImmutableArray.Create("vmware", "vendor", "virtualization")
};
// ===== Linux Distributions =====
public static readonly SourceDefinition Debian = new()
{
Id = "debian",
DisplayName = "Debian Security",
Category = SourceCategory.Distribution,
Type = SourceType.Upstream,
Description = "Debian Security Tracker",
BaseEndpoint = "https://security-tracker.debian.org/tracker/data/json",
HealthCheckEndpoint = "https://security-tracker.debian.org/tracker/",
HttpClientName = "DebianClient",
RequiresAuthentication = false,
StatusPageUrl = "https://security-tracker.debian.org/tracker/",
DocumentationUrl = "https://www.debian.org/security/",
DefaultPriority = 30,
Tags = ImmutableArray.Create("debian", "distro", "linux")
};
public static readonly SourceDefinition Ubuntu = new()
{
Id = "ubuntu",
DisplayName = "Ubuntu Security",
Category = SourceCategory.Distribution,
Type = SourceType.Upstream,
Description = "Ubuntu Security Notices",
BaseEndpoint = "https://ubuntu.com/security/cves.json",
HealthCheckEndpoint = "https://ubuntu.com/security/cves.json?limit=1",
HttpClientName = "UbuntuClient",
RequiresAuthentication = false,
StatusPageUrl = "https://ubuntu.com/security",
DefaultPriority = 32,
Tags = ImmutableArray.Create("ubuntu", "distro", "linux")
};
public static readonly SourceDefinition Alpine = new()
{
Id = "alpine",
DisplayName = "Alpine Security",
Category = SourceCategory.Distribution,
Type = SourceType.Upstream,
Description = "Alpine Linux Security Database",
BaseEndpoint = "https://secdb.alpinelinux.org/",
HealthCheckEndpoint = "https://secdb.alpinelinux.org/",
HttpClientName = "AlpineClient",
RequiresAuthentication = false,
DefaultPriority = 34,
Tags = ImmutableArray.Create("alpine", "distro", "linux", "container")
};
public static readonly SourceDefinition Suse = new()
{
Id = "suse",
DisplayName = "SUSE Security",
Category = SourceCategory.Distribution,
Type = SourceType.Upstream,
Description = "SUSE Security Updates",
BaseEndpoint = "https://www.suse.com/support/update/",
HealthCheckEndpoint = "https://www.suse.com/support/update/",
HttpClientName = "SuseClient",
RequiresAuthentication = false,
DefaultPriority = 36,
Tags = ImmutableArray.Create("suse", "distro", "linux")
};
public static readonly SourceDefinition Rhel = new()
{
Id = "rhel",
DisplayName = "RHEL Security",
Category = SourceCategory.Distribution,
Type = SourceType.Upstream,
Description = "Red Hat Enterprise Linux Security",
BaseEndpoint = "https://access.redhat.com/hydra/rest/securitydata/",
HealthCheckEndpoint = "https://access.redhat.com/hydra/rest/securitydata/cve.json?per_page=1",
HttpClientName = "RhelClient",
RequiresAuthentication = false,
DefaultPriority = 38,
Tags = ImmutableArray.Create("rhel", "distro", "linux", "enterprise")
};
public static readonly SourceDefinition Centos = new()
{
Id = "centos",
DisplayName = "CentOS Security",
Category = SourceCategory.Distribution,
Type = SourceType.Upstream,
Description = "CentOS Security Updates",
BaseEndpoint = "https://lists.centos.org/pipermail/centos-announce/",
HealthCheckEndpoint = "https://lists.centos.org/pipermail/centos-announce/",
HttpClientName = "CentosClient",
RequiresAuthentication = false,
DefaultPriority = 40,
Tags = ImmutableArray.Create("centos", "distro", "linux")
};
public static readonly SourceDefinition Fedora = new()
{
Id = "fedora",
DisplayName = "Fedora Security",
Category = SourceCategory.Distribution,
Type = SourceType.Upstream,
Description = "Fedora Security Updates",
BaseEndpoint = "https://bodhi.fedoraproject.org/updates/",
HealthCheckEndpoint = "https://bodhi.fedoraproject.org/updates/?status=stable&type=security&rows_per_page=1",
HttpClientName = "FedoraClient",
RequiresAuthentication = false,
DefaultPriority = 42,
Tags = ImmutableArray.Create("fedora", "distro", "linux")
};
public static readonly SourceDefinition Arch = new()
{
Id = "arch",
DisplayName = "Arch Security",
Category = SourceCategory.Distribution,
Type = SourceType.Upstream,
Description = "Arch Linux Security Tracker",
BaseEndpoint = "https://security.archlinux.org/",
HealthCheckEndpoint = "https://security.archlinux.org/issues/all.json",
HttpClientName = "ArchClient",
RequiresAuthentication = false,
DefaultPriority = 44,
Tags = ImmutableArray.Create("arch", "distro", "linux")
};
public static readonly SourceDefinition Gentoo = new()
{
Id = "gentoo",
DisplayName = "Gentoo Security",
Category = SourceCategory.Distribution,
Type = SourceType.Upstream,
Description = "Gentoo Linux Security Advisories",
BaseEndpoint = "https://security.gentoo.org/glsa/feed.rss",
HealthCheckEndpoint = "https://security.gentoo.org/",
HttpClientName = "GentooClient",
RequiresAuthentication = false,
DefaultPriority = 46,
Tags = ImmutableArray.Create("gentoo", "distro", "linux")
};
// ===== Language Ecosystems =====
public static readonly SourceDefinition Npm = new()
{
Id = "npm",
DisplayName = "npm Advisories",
Category = SourceCategory.Ecosystem,
Type = SourceType.Upstream,
Description = "npm Security Advisories (via OSV)",
BaseEndpoint = "https://api.osv.dev/v1",
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
HttpClientName = "NpmClient",
RequiresAuthentication = false,
DefaultPriority = 50,
Tags = ImmutableArray.Create("npm", "ecosystem", "javascript", "node")
};
public static readonly SourceDefinition PyPi = new()
{
Id = "pypi",
DisplayName = "PyPI Advisories",
Category = SourceCategory.Ecosystem,
Type = SourceType.Upstream,
Description = "Python Package Index Security Advisories (via OSV)",
BaseEndpoint = "https://api.osv.dev/v1",
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
HttpClientName = "PyPiClient",
RequiresAuthentication = false,
DefaultPriority = 52,
Tags = ImmutableArray.Create("pypi", "ecosystem", "python")
};
public static readonly SourceDefinition Go = new()
{
Id = "go",
DisplayName = "Go Advisories",
Category = SourceCategory.Ecosystem,
Type = SourceType.Upstream,
Description = "Go Vulnerability Database",
BaseEndpoint = "https://vuln.go.dev/",
HealthCheckEndpoint = "https://vuln.go.dev/",
HttpClientName = "GoClient",
RequiresAuthentication = false,
DefaultPriority = 54,
Tags = ImmutableArray.Create("go", "ecosystem", "golang")
};
public static readonly SourceDefinition RubyGems = new()
{
Id = "rubygems",
DisplayName = "RubyGems Advisories",
Category = SourceCategory.Ecosystem,
Type = SourceType.Upstream,
Description = "RubyGems Security Advisories (via OSV)",
BaseEndpoint = "https://api.osv.dev/v1",
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
HttpClientName = "RubyGemsClient",
RequiresAuthentication = false,
DefaultPriority = 56,
Tags = ImmutableArray.Create("rubygems", "ecosystem", "ruby")
};
public static readonly SourceDefinition Nuget = new()
{
Id = "nuget",
DisplayName = "NuGet Advisories",
Category = SourceCategory.Ecosystem,
Type = SourceType.Upstream,
Description = "NuGet Security Advisories (via GHSA)",
BaseEndpoint = "https://api.github.com/graphql",
HealthCheckEndpoint = "https://api.github.com/zen",
HttpClientName = "NugetClient",
RequiresAuthentication = true,
CredentialEnvVar = "GITHUB_PAT",
DefaultPriority = 58,
Tags = ImmutableArray.Create("nuget", "ecosystem", "dotnet", "csharp")
};
public static readonly SourceDefinition Maven = new()
{
Id = "maven",
DisplayName = "Maven Advisories",
Category = SourceCategory.Ecosystem,
Type = SourceType.Upstream,
Description = "Maven Central Security Advisories (via OSV)",
BaseEndpoint = "https://api.osv.dev/v1",
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
HttpClientName = "MavenClient",
RequiresAuthentication = false,
DefaultPriority = 60,
Tags = ImmutableArray.Create("maven", "ecosystem", "java")
};
public static readonly SourceDefinition Crates = new()
{
Id = "crates",
DisplayName = "Crates.io Advisories",
Category = SourceCategory.Ecosystem,
Type = SourceType.Upstream,
Description = "Rust Crates.io Security Advisories",
BaseEndpoint = "https://rustsec.org/advisories/",
HealthCheckEndpoint = "https://rustsec.org/advisories/",
HttpClientName = "CratesClient",
RequiresAuthentication = false,
DefaultPriority = 62,
Tags = ImmutableArray.Create("crates", "ecosystem", "rust")
};
public static readonly SourceDefinition Packagist = new()
{
Id = "packagist",
DisplayName = "Packagist Advisories",
Category = SourceCategory.Ecosystem,
Type = SourceType.Upstream,
Description = "PHP Packagist Security Advisories (via OSV)",
BaseEndpoint = "https://api.osv.dev/v1",
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
HttpClientName = "PackagistClient",
RequiresAuthentication = false,
DefaultPriority = 64,
Tags = ImmutableArray.Create("packagist", "ecosystem", "php")
};
public static readonly SourceDefinition Hex = new()
{
Id = "hex",
DisplayName = "Hex.pm Advisories",
Category = SourceCategory.Ecosystem,
Type = SourceType.Upstream,
Description = "Elixir Hex.pm Security Advisories (via OSV)",
BaseEndpoint = "https://api.osv.dev/v1",
HealthCheckEndpoint = "https://api.osv.dev/v1/query",
HttpClientName = "HexClient",
RequiresAuthentication = false,
DefaultPriority = 66,
Tags = ImmutableArray.Create("hex", "ecosystem", "elixir", "erlang")
};
// ===== CSAF/VEX Sources =====
public static readonly SourceDefinition Csaf = new()
{
Id = "csaf",
DisplayName = "CSAF Aggregator",
Category = SourceCategory.Csaf,
Type = SourceType.Upstream,
Description = "Common Security Advisory Framework",
BaseEndpoint = "https://csaf-aggregator.oasis-open.org/",
HealthCheckEndpoint = "https://csaf-aggregator.oasis-open.org/",
HttpClientName = "CsafClient",
RequiresAuthentication = false,
DefaultPriority = 70,
Tags = ImmutableArray.Create("csaf", "vex", "structured")
};
public static readonly SourceDefinition CsafTc = new()
{
Id = "csaf-tc",
DisplayName = "CSAF TC Trusted Publishers",
Category = SourceCategory.Csaf,
Type = SourceType.Upstream,
Description = "OASIS CSAF TC Trusted Publisher List",
BaseEndpoint = "https://csaf.io/",
HealthCheckEndpoint = "https://csaf.io/",
HttpClientName = "CsafTcClient",
RequiresAuthentication = false,
DefaultPriority = 72,
Tags = ImmutableArray.Create("csaf", "oasis", "trusted")
};
public static readonly SourceDefinition Vex = new()
{
Id = "vex",
DisplayName = "VEX Hub",
Category = SourceCategory.Csaf,
Type = SourceType.Upstream,
Description = "Vulnerability Exploitability eXchange documents",
BaseEndpoint = "https://vexhub.example.com/",
HealthCheckEndpoint = "https://vexhub.example.com/",
HttpClientName = "VexClient",
RequiresAuthentication = false,
DefaultPriority = 74,
Tags = ImmutableArray.Create("vex", "exploitability")
};
// ===== CERTs =====
public static readonly SourceDefinition CertFr = new()
{
Id = "cert-fr",
DisplayName = "CERT-FR",
Category = SourceCategory.Cert,
Type = SourceType.Upstream,
Description = "French CERT",
BaseEndpoint = "https://www.cert.ssi.gouv.fr/",
HealthCheckEndpoint = "https://www.cert.ssi.gouv.fr/",
HttpClientName = "CertFrClient",
RequiresAuthentication = false,
Regions = ImmutableArray.Create("FR", "EU"),
DefaultPriority = 80,
Tags = ImmutableArray.Create("cert", "france", "eu")
};
public static readonly SourceDefinition CertDe = new()
{
Id = "cert-de",
DisplayName = "CERT-Bund (Germany)",
Category = SourceCategory.Cert,
Type = SourceType.Upstream,
Description = "German Federal CERT",
BaseEndpoint = "https://www.bsi.bund.de/DE/Themen/Unternehmen-und-Organisationen/Cyber-Sicherheitslage/Technische-Sicherheitshinweise/",
HealthCheckEndpoint = "https://www.bsi.bund.de/",
HttpClientName = "CertDeClient",
RequiresAuthentication = false,
Regions = ImmutableArray.Create("DE", "EU"),
DefaultPriority = 82,
Tags = ImmutableArray.Create("cert", "germany", "eu")
};
public static readonly SourceDefinition CertAt = new()
{
Id = "cert-at",
DisplayName = "CERT.at (Austria)",
Category = SourceCategory.Cert,
Type = SourceType.Upstream,
Description = "Austrian CERT",
BaseEndpoint = "https://cert.at/",
HealthCheckEndpoint = "https://cert.at/",
HttpClientName = "CertAtClient",
RequiresAuthentication = false,
Regions = ImmutableArray.Create("AT", "EU"),
DefaultPriority = 84,
Tags = ImmutableArray.Create("cert", "austria", "eu")
};
public static readonly SourceDefinition CertBe = new()
{
Id = "cert-be",
DisplayName = "CERT.be (Belgium)",
Category = SourceCategory.Cert,
Type = SourceType.Upstream,
Description = "Belgian CERT",
BaseEndpoint = "https://cert.be/",
HealthCheckEndpoint = "https://cert.be/",
HttpClientName = "CertBeClient",
RequiresAuthentication = false,
Regions = ImmutableArray.Create("BE", "EU"),
DefaultPriority = 86,
Tags = ImmutableArray.Create("cert", "belgium", "eu")
};
public static readonly SourceDefinition CertCh = new()
{
Id = "cert-ch",
DisplayName = "NCSC-CH (Switzerland)",
Category = SourceCategory.Cert,
Type = SourceType.Upstream,
Description = "Swiss National Cyber Security Centre",
BaseEndpoint = "https://www.ncsc.admin.ch/",
HealthCheckEndpoint = "https://www.ncsc.admin.ch/",
HttpClientName = "CertChClient",
RequiresAuthentication = false,
Regions = ImmutableArray.Create("CH"),
DefaultPriority = 88,
Tags = ImmutableArray.Create("cert", "switzerland")
};
public static readonly SourceDefinition CertEu = new()
{
Id = "cert-eu",
DisplayName = "CERT-EU",
Category = SourceCategory.Cert,
Type = SourceType.Upstream,
Description = "EU CERT for EU Institutions",
BaseEndpoint = "https://cert.europa.eu/",
HealthCheckEndpoint = "https://cert.europa.eu/",
HttpClientName = "CertEuClient",
RequiresAuthentication = false,
Regions = ImmutableArray.Create("EU"),
DefaultPriority = 90,
Tags = ImmutableArray.Create("cert", "eu")
};
public static readonly SourceDefinition JpCert = new()
{
Id = "jpcert",
DisplayName = "JPCERT/CC (Japan)",
Category = SourceCategory.Cert,
Type = SourceType.Upstream,
Description = "Japan Computer Emergency Response Team",
BaseEndpoint = "https://www.jpcert.or.jp/english/",
HealthCheckEndpoint = "https://www.jpcert.or.jp/english/",
HttpClientName = "JpCertClient",
RequiresAuthentication = false,
Regions = ImmutableArray.Create("JP", "APAC"),
DefaultPriority = 92,
Tags = ImmutableArray.Create("cert", "japan", "apac")
};
public static readonly SourceDefinition UsCert = new()
{
Id = "us-cert",
DisplayName = "CISA (US-CERT)",
Category = SourceCategory.Cert,
Type = SourceType.Upstream,
Description = "US Cybersecurity and Infrastructure Security Agency",
BaseEndpoint = "https://www.cisa.gov/news-events/cybersecurity-advisories",
HealthCheckEndpoint = "https://www.cisa.gov/",
HttpClientName = "UsCertClient",
RequiresAuthentication = false,
Regions = ImmutableArray.Create("US", "NA"),
DefaultPriority = 94,
Tags = ImmutableArray.Create("cert", "us", "cisa")
};
// ===== StellaOps Mirror =====
public static readonly SourceDefinition StellaMirror = new()
{
Id = "stella-mirror",
DisplayName = "StellaOps Mirror",
Category = SourceCategory.Mirror,
Type = SourceType.StellaMirror,
Description = "StellaOps Pre-aggregated Advisory Mirror",
BaseEndpoint = "https://mirror.stella-ops.org/api/v1",
HealthCheckEndpoint = "https://mirror.stella-ops.org/api/v1/health",
HttpClientName = "StellaMirrorClient",
RequiresAuthentication = false, // Can be configured for OAuth
StatusPageUrl = "https://status.stella-ops.org/",
DocumentationUrl = "https://docs.stella-ops.org/mirror/",
DefaultPriority = 1, // Highest priority when using mirror mode
Tags = ImmutableArray.Create("stella", "mirror", "aggregated")
};
// ===== All Sources Collection =====
/// <summary>
/// All registered source definitions.
/// Must be declared after all individual sources due to static initialization order.
/// </summary>
public static readonly ImmutableArray<SourceDefinition> All = ImmutableArray.Create(
// Primary databases
Nvd, Osv, Ghsa, Cve, Epss, Kev,
// Vendor advisories
RedHat, Microsoft, Amazon, Google, Oracle, Apple, Cisco, Fortinet, Juniper, Palo, Vmware,
// Linux distributions
Debian, Ubuntu, Alpine, Suse, Rhel, Centos, Fedora, Arch, Gentoo,
// Ecosystems
Npm, PyPi, Go, RubyGems, Nuget, Maven, Crates, Packagist, Hex,
// CSAF/VEX
Csaf, CsafTc, Vex,
// CERTs
CertFr, CertDe, CertAt, CertBe, CertCh, CertEu, JpCert, UsCert,
// Mirrors
StellaMirror);
// ===== Helper Methods =====
/// <summary>
/// Get sources by category.
/// </summary>
public static ImmutableArray<SourceDefinition> GetByCategory(SourceCategory category)
=> All.Where(s => s.Category == category).ToImmutableArray();
/// <summary>
/// Get sources by tag.
/// </summary>
public static ImmutableArray<SourceDefinition> GetByTag(string tag)
=> All.Where(s => s.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)).ToImmutableArray();
/// <summary>
/// Get sources by region.
/// </summary>
public static ImmutableArray<SourceDefinition> GetByRegion(string region)
=> All.Where(s => s.Regions.Length == 0 || s.Regions.Contains(region, StringComparer.OrdinalIgnoreCase))
.ToImmutableArray();
/// <summary>
/// Get sources that require authentication.
/// </summary>
public static ImmutableArray<SourceDefinition> GetAuthenticatedSources()
=> All.Where(s => s.RequiresAuthentication).ToImmutableArray();
/// <summary>
/// Find a source by ID.
/// </summary>
public static SourceDefinition? FindById(string sourceId)
=> All.FirstOrDefault(s => s.Id.Equals(sourceId, StringComparison.OrdinalIgnoreCase));
}

View File

@@ -0,0 +1,427 @@
// -----------------------------------------------------------------------------
// SourceErrorDetails.cs
// Sprint: SPRINT_20260114_SOURCES_SETUP
// Task: 3.3 - Source Error Details
// Description: Detailed error information with why/how explanations
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Net;
namespace StellaOps.Concelier.Core.Sources;
/// <summary>
/// Detailed error information for a source connectivity failure.
/// Provides "why" and "how to fix" explanations for users.
/// </summary>
public sealed record SourceErrorDetails
{
/// <summary>
/// Error code for categorization.
/// </summary>
public required string Code { get; init; }
/// <summary>
/// Human-readable error message.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// HTTP status code if applicable.
/// </summary>
public int? HttpStatusCode { get; init; }
/// <summary>
/// Possible reasons for the failure.
/// </summary>
public ImmutableArray<string> PossibleReasons { get; init; }
= ImmutableArray<string>.Empty;
/// <summary>
/// Steps to remediate the issue.
/// </summary>
public ImmutableArray<RemediationStep> RemediationSteps { get; init; }
= ImmutableArray<RemediationStep>.Empty;
}
/// <summary>
/// Factory for creating source-specific error details.
/// </summary>
public static class SourceErrorFactory
{
/// <summary>
/// Create error details from an HTTP response.
/// </summary>
public static SourceErrorDetails FromHttpResponse(
SourceDefinition source,
HttpStatusCode statusCode,
string? responseBody = null)
{
return statusCode switch
{
HttpStatusCode.Unauthorized => CreateAuthError(source, statusCode),
HttpStatusCode.Forbidden => CreateForbiddenError(source, statusCode),
HttpStatusCode.NotFound => CreateNotFoundError(source, statusCode),
HttpStatusCode.TooManyRequests => CreateRateLimitError(source, statusCode),
HttpStatusCode.ServiceUnavailable => CreateServiceDownError(source, statusCode),
HttpStatusCode.BadGateway => CreateNetworkError(source, statusCode),
HttpStatusCode.GatewayTimeout => CreateTimeoutError(source, statusCode),
_ => CreateGenericHttpError(source, statusCode, responseBody)
};
}
/// <summary>
/// Create error details from a network exception.
/// </summary>
public static SourceErrorDetails FromNetworkException(
SourceDefinition source,
Exception exception)
{
return exception switch
{
HttpRequestException httpEx when httpEx.InnerException is System.Net.Sockets.SocketException =>
CreateDnsError(source),
HttpRequestException httpEx when httpEx.Message.Contains("SSL", StringComparison.OrdinalIgnoreCase) =>
CreateSslError(source),
TaskCanceledException =>
CreateTimeoutError(source, null),
_ => CreateNetworkError(source, null)
};
}
private static SourceErrorDetails CreateAuthError(SourceDefinition source, HttpStatusCode statusCode)
{
var (envVar, tokenUrl, tokenScopes) = GetSourceCredentialInfo(source.Id);
return new SourceErrorDetails
{
Code = "AUTH_REQUIRED",
Message = $"Authentication failed for {source.DisplayName}",
HttpStatusCode = (int)statusCode,
PossibleReasons = ImmutableArray.Create(
$"API key or token for {source.DisplayName} is not set",
"The API key may have expired",
"The API key may have been revoked"),
RemediationSteps = ImmutableArray.Create(
new RemediationStep
{
Order = 1,
Description = $"Obtain an API key from {source.DisplayName}",
DocumentationUrl = tokenUrl,
CommandType = CommandType.Url
},
new RemediationStep
{
Order = 2,
Description = $"Set the {envVar} environment variable",
Command = $"export {envVar}=\"your-api-key\"",
CommandType = CommandType.Bash
},
new RemediationStep
{
Order = 3,
Description = "Retry the connectivity check",
Command = $"stella sources check --source {source.Id}",
CommandType = CommandType.StellaCli
})
};
}
private static SourceErrorDetails CreateForbiddenError(SourceDefinition source, HttpStatusCode statusCode)
{
var (envVar, tokenUrl, tokenScopes) = GetSourceCredentialInfo(source.Id);
return new SourceErrorDetails
{
Code = "ACCESS_DENIED",
Message = $"Access denied to {source.DisplayName}",
HttpStatusCode = (int)statusCode,
PossibleReasons = ImmutableArray.Create(
"The API key lacks required permissions/scopes",
"Your account may not have access to this resource",
"IP-based access restrictions may be in place"),
RemediationSteps = ImmutableArray.Create(
new RemediationStep
{
Order = 1,
Description = $"Verify your API key has the required scopes: {tokenScopes}",
DocumentationUrl = tokenUrl,
CommandType = CommandType.Url
},
new RemediationStep
{
Order = 2,
Description = "Generate a new API key with correct permissions",
CommandType = CommandType.Url,
DocumentationUrl = tokenUrl
})
};
}
private static SourceErrorDetails CreateNotFoundError(SourceDefinition source, HttpStatusCode statusCode)
{
return new SourceErrorDetails
{
Code = "ENDPOINT_NOT_FOUND",
Message = $"Endpoint not found for {source.DisplayName}",
HttpStatusCode = (int)statusCode,
PossibleReasons = ImmutableArray.Create(
"The API endpoint may have changed",
"The source may be temporarily unavailable",
"A custom endpoint URL may be incorrect"),
RemediationSteps = ImmutableArray.Create(
new RemediationStep
{
Order = 1,
Description = $"Check the {source.DisplayName} status page",
DocumentationUrl = source.StatusPageUrl,
CommandType = CommandType.Url
},
new RemediationStep
{
Order = 2,
Description = "Verify custom endpoint configuration if set",
Command = "stella config get concelier.sources",
CommandType = CommandType.StellaCli
})
};
}
private static SourceErrorDetails CreateRateLimitError(SourceDefinition source, HttpStatusCode statusCode)
{
var (envVar, tokenUrl, _) = GetSourceCredentialInfo(source.Id);
return new SourceErrorDetails
{
Code = "RATE_LIMITED",
Message = $"Rate limit exceeded for {source.DisplayName}",
HttpStatusCode = (int)statusCode,
PossibleReasons = ImmutableArray.Create(
"Too many requests in a short period",
"API key may have lower rate limits",
"Multiple instances may be sharing the same key"),
RemediationSteps = ImmutableArray.Create(
new RemediationStep
{
Order = 1,
Description = "Wait a few minutes and retry",
CommandType = CommandType.Bash
},
new RemediationStep
{
Order = 2,
Description = "Consider using an API key for higher rate limits",
DocumentationUrl = tokenUrl,
CommandType = CommandType.Url
},
new RemediationStep
{
Order = 3,
Description = "Adjust request delay in configuration",
Command = $"stella config set concelier.sources.{source.Id}.requestDelay 00:00:01",
CommandType = CommandType.StellaCli
})
};
}
private static SourceErrorDetails CreateServiceDownError(SourceDefinition source, HttpStatusCode statusCode)
{
return new SourceErrorDetails
{
Code = "SERVICE_DOWN",
Message = $"{source.DisplayName} is currently unavailable",
HttpStatusCode = (int)statusCode,
PossibleReasons = ImmutableArray.Create(
"The service may be undergoing maintenance",
"There may be an outage affecting the service",
"Network routing issues may be occurring"),
RemediationSteps = ImmutableArray.Create(
new RemediationStep
{
Order = 1,
Description = $"Check {source.DisplayName} status page",
DocumentationUrl = source.StatusPageUrl,
CommandType = CommandType.Url
},
new RemediationStep
{
Order = 2,
Description = "Wait and retry later",
CommandType = CommandType.Bash
},
new RemediationStep
{
Order = 3,
Description = "Consider using cached/offline data temporarily",
Command = "stella sources enable-offline-fallback",
CommandType = CommandType.StellaCli
})
};
}
private static SourceErrorDetails CreateNetworkError(SourceDefinition source, HttpStatusCode? statusCode)
{
return new SourceErrorDetails
{
Code = "NETWORK_ERROR",
Message = $"Network error connecting to {source.DisplayName}",
HttpStatusCode = statusCode.HasValue ? (int)statusCode.Value : null,
PossibleReasons = ImmutableArray.Create(
"Firewall may be blocking the connection",
"Proxy configuration may be required",
"Network connectivity issues"),
RemediationSteps = ImmutableArray.Create(
new RemediationStep
{
Order = 1,
Description = "Check network connectivity",
Command = $"curl -v {source.HealthCheckEndpoint}",
CommandType = CommandType.Bash
},
new RemediationStep
{
Order = 2,
Description = "Verify firewall rules allow outbound HTTPS",
CommandType = CommandType.Bash
},
new RemediationStep
{
Order = 3,
Description = "Configure proxy if required",
Command = "export HTTPS_PROXY=http://proxy:8080",
CommandType = CommandType.Bash
})
};
}
private static SourceErrorDetails CreateDnsError(SourceDefinition source)
{
return new SourceErrorDetails
{
Code = "DNS_ERROR",
Message = $"DNS resolution failed for {source.DisplayName}",
PossibleReasons = ImmutableArray.Create(
"DNS server may be unreachable",
"The hostname may be incorrect",
"Network configuration issues"),
RemediationSteps = ImmutableArray.Create(
new RemediationStep
{
Order = 1,
Description = "Check DNS resolution",
Command = $"nslookup {new Uri(source.HealthCheckEndpoint).Host}",
CommandType = CommandType.Bash
},
new RemediationStep
{
Order = 2,
Description = "Verify DNS server configuration",
CommandType = CommandType.Bash
})
};
}
private static SourceErrorDetails CreateSslError(SourceDefinition source)
{
return new SourceErrorDetails
{
Code = "SSL_ERROR",
Message = $"SSL/TLS error connecting to {source.DisplayName}",
PossibleReasons = ImmutableArray.Create(
"SSL certificate may be invalid or expired",
"Certificate chain verification failed",
"TLS version mismatch"),
RemediationSteps = ImmutableArray.Create(
new RemediationStep
{
Order = 1,
Description = "Verify the SSL certificate",
Command = $"openssl s_client -connect {new Uri(source.HealthCheckEndpoint).Host}:443",
CommandType = CommandType.Bash
},
new RemediationStep
{
Order = 2,
Description = "Update CA certificates if outdated",
Command = "sudo update-ca-certificates",
CommandType = CommandType.Bash
})
};
}
private static SourceErrorDetails CreateTimeoutError(SourceDefinition source, HttpStatusCode? statusCode)
{
return new SourceErrorDetails
{
Code = "TIMEOUT",
Message = $"Connection to {source.DisplayName} timed out",
HttpStatusCode = statusCode.HasValue ? (int)statusCode.Value : null,
PossibleReasons = ImmutableArray.Create(
"The service may be slow or overloaded",
"Network latency may be high",
"Connection timeout may be too short"),
RemediationSteps = ImmutableArray.Create(
new RemediationStep
{
Order = 1,
Description = "Increase connection timeout",
Command = "stella config set concelier.connectivityCheckTimeoutSeconds 60",
CommandType = CommandType.StellaCli
},
new RemediationStep
{
Order = 2,
Description = "Check network latency",
Command = $"ping {new Uri(source.HealthCheckEndpoint).Host}",
CommandType = CommandType.Bash
})
};
}
private static SourceErrorDetails CreateGenericHttpError(
SourceDefinition source,
HttpStatusCode statusCode,
string? responseBody)
{
return new SourceErrorDetails
{
Code = "HTTP_ERROR",
Message = $"HTTP {(int)statusCode} response from {source.DisplayName}",
HttpStatusCode = (int)statusCode,
PossibleReasons = ImmutableArray.Create(
"An unexpected error occurred",
"The API may be experiencing issues"),
RemediationSteps = ImmutableArray.Create(
new RemediationStep
{
Order = 1,
Description = $"Check {source.DisplayName} status page",
DocumentationUrl = source.StatusPageUrl,
CommandType = CommandType.Url
},
new RemediationStep
{
Order = 2,
Description = "Retry the connectivity check",
Command = $"stella sources check --source {source.Id}",
CommandType = CommandType.StellaCli
})
};
}
private static (string EnvVar, string TokenUrl, string Scopes) GetSourceCredentialInfo(string sourceId)
{
return sourceId.ToUpperInvariant() switch
{
"NVD" => ("NVD_API_KEY", "https://nvd.nist.gov/developers/request-an-api-key", "N/A"),
"GHSA" => ("GITHUB_PAT", "https://github.com/settings/tokens", "read:packages, read:org"),
"OSV" => ("OSV_API_KEY", "https://osv.dev", "N/A (optional)"),
"EPSS" => ("EPSS_API_KEY", "https://www.first.org/epss/api", "N/A (no auth required)"),
"KEV" => ("KEV_API_KEY", "https://www.cisa.gov/known-exploited-vulnerabilities-catalog", "N/A (no auth)"),
"REDHAT" => ("REDHAT_API_KEY", "https://access.redhat.com/hydra/rest/securitydata/", "N/A"),
"DEBIAN" => ("DEBIAN_API_KEY", "https://security-tracker.debian.org/", "N/A (no auth)"),
"UBUNTU" => ("UBUNTU_API_KEY", "https://ubuntu.com/security/cves", "N/A (no auth)"),
_ => ($"{sourceId.ToUpperInvariant()}_API_KEY", "", "")
};
}
}

View File

@@ -0,0 +1,369 @@
// -----------------------------------------------------------------------------
// SourceRegistry.cs
// Sprint: SPRINT_20260114_SOURCES_SETUP
// Task: 3.3 - Source Registry Implementation
// Description: Registry for managing and checking advisory source connectivity
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.Core.Configuration;
namespace StellaOps.Concelier.Core.Sources;
/// <summary>
/// Default implementation of <see cref="ISourceRegistry"/>.
/// Manages source connectivity checking with auto-enable for healthy sources.
/// </summary>
public sealed class SourceRegistry : ISourceRegistry
{
private readonly ImmutableArray<SourceDefinition> _sources;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<SourceRegistry> _logger;
private readonly TimeProvider _timeProvider;
private readonly SourcesConfiguration _configuration;
private readonly ConcurrentDictionary<string, bool> _enabledSources;
private readonly ConcurrentDictionary<string, SourceConnectivityResult> _lastCheckResults;
public SourceRegistry(
IHttpClientFactory httpClientFactory,
ILogger<SourceRegistry> logger,
TimeProvider? timeProvider = null,
SourcesConfiguration? configuration = null)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_configuration = configuration ?? new SourcesConfiguration();
_sources = SourceDefinitions.All;
_enabledSources = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
_lastCheckResults = new ConcurrentDictionary<string, SourceConnectivityResult>(StringComparer.OrdinalIgnoreCase);
// Initialize enabled state from definitions
foreach (var source in _sources)
{
_enabledSources[source.Id] = source.EnabledByDefault;
}
}
/// <inheritdoc />
public IReadOnlyList<SourceDefinition> GetAllSources() => _sources;
/// <inheritdoc />
public SourceDefinition? GetSource(string sourceId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
return _sources.FirstOrDefault(s => s.Id.Equals(sourceId, StringComparison.OrdinalIgnoreCase));
}
/// <inheritdoc />
public IReadOnlyList<SourceDefinition> GetSourcesByCategory(SourceCategory category)
=> _sources.Where(s => s.Category == category).ToList();
/// <inheritdoc />
public async Task<SourceConnectivityResult> CheckConnectivityAsync(
string sourceId,
CancellationToken cancellationToken = default)
{
var source = GetSource(sourceId);
if (source is null)
{
var notFound = SourceConnectivityResult.NotFound(sourceId);
_lastCheckResults[sourceId] = notFound;
return notFound;
}
var stopwatch = Stopwatch.StartNew();
var checkedAt = _timeProvider.GetUtcNow();
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(_configuration.ConnectivityCheckTimeoutSeconds));
var client = _httpClientFactory.CreateClient(source.HttpClientName);
// Set appropriate headers for the source
ConfigureClientHeaders(client, source);
_logger.LogDebug(
"Checking connectivity for source {SourceId} at {Endpoint}",
sourceId, source.HealthCheckEndpoint);
var response = await client.GetAsync(
source.HealthCheckEndpoint,
HttpCompletionOption.ResponseHeadersRead,
cts.Token);
stopwatch.Stop();
if (response.IsSuccessStatusCode)
{
var result = SourceConnectivityResult.Healthy(sourceId, stopwatch.Elapsed, checkedAt);
_lastCheckResults[sourceId] = result;
_logger.LogInformation(
"Source {SourceId} is healthy (latency: {Latency}ms)",
sourceId, stopwatch.Elapsed.TotalMilliseconds);
return result;
}
// Handle non-success status codes
var errorDetails = SourceErrorFactory.FromHttpResponse(source, response.StatusCode);
var failedResult = SourceConnectivityResult.Failed(
sourceId,
errorDetails.Code,
errorDetails.Message,
errorDetails.PossibleReasons,
errorDetails.RemediationSteps,
checkedAt,
stopwatch.Elapsed,
(int)response.StatusCode);
_lastCheckResults[sourceId] = failedResult;
_logger.LogWarning(
"Source {SourceId} failed connectivity check: {StatusCode} - {ErrorMessage}",
sourceId, response.StatusCode, errorDetails.Message);
return failedResult;
}
catch (HttpRequestException ex)
{
stopwatch.Stop();
var errorDetails = SourceErrorFactory.FromNetworkException(source, ex);
var failedResult = SourceConnectivityResult.Failed(
sourceId,
errorDetails.Code,
errorDetails.Message,
errorDetails.PossibleReasons,
errorDetails.RemediationSteps,
checkedAt,
stopwatch.Elapsed);
_lastCheckResults[sourceId] = failedResult;
_logger.LogWarning(ex,
"Source {SourceId} failed connectivity check (network error): {ErrorMessage}",
sourceId, errorDetails.Message);
return failedResult;
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException)
{
stopwatch.Stop();
var errorDetails = SourceErrorFactory.FromNetworkException(source, new TaskCanceledException());
var failedResult = SourceConnectivityResult.Failed(
sourceId,
errorDetails.Code,
errorDetails.Message,
errorDetails.PossibleReasons,
errorDetails.RemediationSteps,
checkedAt,
stopwatch.Elapsed);
_lastCheckResults[sourceId] = failedResult;
_logger.LogWarning(
"Source {SourceId} connectivity check timed out after {Timeout}s",
sourceId, _configuration.ConnectivityCheckTimeoutSeconds);
return failedResult;
}
catch (Exception ex)
{
stopwatch.Stop();
var failedResult = SourceConnectivityResult.Failed(
sourceId,
"UNEXPECTED_ERROR",
$"Unexpected error: {ex.Message}",
ImmutableArray.Create("An unexpected error occurred during connectivity check"),
ImmutableArray.Create(new RemediationStep
{
Order = 1,
Description = "Check the logs for detailed error information",
CommandType = CommandType.Bash
}),
checkedAt,
stopwatch.Elapsed);
_lastCheckResults[sourceId] = failedResult;
_logger.LogError(ex,
"Source {SourceId} connectivity check failed with unexpected error",
sourceId);
return failedResult;
}
}
/// <inheritdoc />
public async Task<SourceCheckResult> CheckAllAndAutoConfigureAsync(
CancellationToken cancellationToken = default)
{
var startTime = _timeProvider.GetUtcNow();
var stopwatch = Stopwatch.StartNew();
_logger.LogInformation(
"Starting connectivity check for {SourceCount} sources",
_sources.Length);
// Check all sources in parallel with limited concurrency
var semaphore = new SemaphoreSlim(10); // Max 10 concurrent checks
var tasks = _sources.Select(async source =>
{
await semaphore.WaitAsync(cancellationToken);
try
{
return await CheckConnectivityAsync(source.Id, cancellationToken);
}
finally
{
semaphore.Release();
}
});
var results = await Task.WhenAll(tasks);
stopwatch.Stop();
// Auto-configure based on results
if (_configuration.AutoEnableHealthySources)
{
foreach (var result in results)
{
_enabledSources[result.SourceId] = result.IsHealthy;
}
}
var checkResult = SourceCheckResult.FromResults(results, startTime, stopwatch.Elapsed);
_logger.LogInformation(
"Connectivity check completed: {HealthyCount}/{Total} healthy, {FailedCount} failed (duration: {Duration}ms)",
checkResult.HealthyCount,
checkResult.TotalChecked,
checkResult.FailedCount,
stopwatch.Elapsed.TotalMilliseconds);
return checkResult;
}
/// <inheritdoc />
public async Task<ImmutableArray<SourceConnectivityResult>> CheckMultipleAsync(
IEnumerable<string> sourceIds,
CancellationToken cancellationToken = default)
{
var ids = sourceIds.ToList();
var tasks = ids.Select(id => CheckConnectivityAsync(id, cancellationToken));
var results = await Task.WhenAll(tasks);
return results.ToImmutableArray();
}
/// <inheritdoc />
public Task<bool> EnableSourceAsync(
string sourceId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
var source = GetSource(sourceId);
if (source is null)
{
_logger.LogWarning("Attempted to enable unknown source: {SourceId}", sourceId);
return Task.FromResult(false);
}
_enabledSources[sourceId] = true;
_logger.LogInformation("Enabled source: {SourceId}", sourceId);
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> DisableSourceAsync(
string sourceId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sourceId);
var source = GetSource(sourceId);
if (source is null)
{
_logger.LogWarning("Attempted to disable unknown source: {SourceId}", sourceId);
return Task.FromResult(false);
}
_enabledSources[sourceId] = false;
_logger.LogInformation("Disabled source: {SourceId}", sourceId);
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<ImmutableArray<string>> GetEnabledSourcesAsync(
CancellationToken cancellationToken = default)
{
var enabled = _enabledSources
.Where(kvp => kvp.Value)
.Select(kvp => kvp.Key)
.ToImmutableArray();
return Task.FromResult(enabled);
}
/// <inheritdoc />
public Task<SourceConnectivityResult> RetryCheckAsync(
string sourceId,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Retrying connectivity check for source: {SourceId}", sourceId);
return CheckConnectivityAsync(sourceId, cancellationToken);
}
/// <summary>
/// Get the last connectivity check result for a source.
/// </summary>
public SourceConnectivityResult? GetLastCheckResult(string sourceId)
{
return _lastCheckResults.GetValueOrDefault(sourceId);
}
/// <summary>
/// Check if a source is currently enabled.
/// </summary>
public bool IsEnabled(string sourceId)
{
return _enabledSources.GetValueOrDefault(sourceId);
}
private static void ConfigureClientHeaders(HttpClient client, SourceDefinition source)
{
// Set a reasonable timeout
client.Timeout = TimeSpan.FromSeconds(30);
// Set User-Agent
if (!client.DefaultRequestHeaders.Contains("User-Agent"))
{
client.DefaultRequestHeaders.Add(
"User-Agent",
$"StellaOps.Concelier/{typeof(SourceRegistry).Assembly.GetName().Version} (+https://stella-ops.org)");
}
// Set Accept header for JSON APIs
if (!client.DefaultRequestHeaders.Contains("Accept"))
{
client.DefaultRequestHeaders.Add("Accept", "application/json");
}
// Source-specific headers would be configured via named HttpClient in DI
}
}

View File

@@ -0,0 +1,174 @@
// -----------------------------------------------------------------------------
// SourcesServiceCollectionExtensions.cs
// Sprint: SPRINT_20260114_SOURCES_SETUP
// Task: 3.3 - Source Registry DI Registration
// Description: Extension methods for registering source services
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Concelier.Core.Configuration;
namespace StellaOps.Concelier.Core.Sources;
/// <summary>
/// Extension methods for registering advisory source services.
/// </summary>
public static class SourcesServiceCollectionExtensions
{
/// <summary>
/// Adds advisory source registry and related services.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration instance.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddSourcesRegistry(
this IServiceCollection services,
IConfiguration configuration)
{
// Bind configuration
services.Configure<SourcesConfiguration>(
configuration.GetSection("sources"));
// Register TimeProvider if not already registered
services.TryAddSingleton(TimeProvider.System);
// Register the source registry
services.AddSingleton<ISourceRegistry, SourceRegistry>();
// Configure HTTP clients for sources
ConfigureSourceHttpClients(services);
return services;
}
/// <summary>
/// Adds advisory source registry with custom configuration.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configure">Configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddSourcesRegistry(
this IServiceCollection services,
Action<SourcesConfiguration> configure)
{
var config = new SourcesConfiguration();
configure(config);
services.AddSingleton(config);
// Register TimeProvider if not already registered
services.TryAddSingleton(TimeProvider.System);
// Register the source registry
services.AddSingleton<ISourceRegistry, SourceRegistry>();
// Configure HTTP clients for sources
ConfigureSourceHttpClients(services);
return services;
}
private static void ConfigureSourceHttpClients(IServiceCollection services)
{
// Configure named HTTP clients for each source
// These can be overridden by the application for custom configuration
// NVD client
services.AddHttpClient("NvdClient", client =>
{
client.BaseAddress = new Uri("https://services.nvd.nist.gov/rest/json/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(30);
});
// OSV client
services.AddHttpClient("OsvClient", client =>
{
client.BaseAddress = new Uri("https://api.osv.dev/v1/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(30);
});
// GHSA client
services.AddHttpClient("GhsaClient", client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.DefaultRequestHeaders.Add("User-Agent", "StellaOps-Concelier");
client.Timeout = TimeSpan.FromSeconds(30);
});
// KEV client
services.AddHttpClient("KevClient", client =>
{
client.BaseAddress = new Uri("https://www.cisa.gov/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(30);
});
// EPSS client
services.AddHttpClient("EpssClient", client =>
{
client.BaseAddress = new Uri("https://api.first.org/data/v1/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(30);
});
// CVE/MITRE client
services.AddHttpClient("CveClient", client =>
{
client.BaseAddress = new Uri("https://cveawg.mitre.org/api/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(30);
});
// Red Hat client
services.AddHttpClient("RedHatClient", client =>
{
client.BaseAddress = new Uri("https://access.redhat.com/hydra/rest/securitydata/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(30);
});
// Debian client
services.AddHttpClient("DebianClient", client =>
{
client.BaseAddress = new Uri("https://security-tracker.debian.org/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(30);
});
// Ubuntu client
services.AddHttpClient("UbuntuClient", client =>
{
client.BaseAddress = new Uri("https://ubuntu.com/security/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(30);
});
// Alpine client
services.AddHttpClient("AlpineClient", client =>
{
client.BaseAddress = new Uri("https://secdb.alpinelinux.org/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(30);
});
// StellaOps Mirror client
services.AddHttpClient("StellaMirrorClient", client =>
{
// Base address would be configured from settings
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(60);
});
// Default client for sources without specific configuration
services.AddHttpClient("DefaultSourceClient", client =>
{
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(30);
});
}
}

View File

@@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />

View File

@@ -0,0 +1,285 @@
using StellaOps.Concelier.BackportProof.Models;
using Xunit;
namespace StellaOps.Concelier.BackportProof.Tests;
/// <summary>
/// Tests for PackageEcosystem enum.
/// </summary>
public sealed class PackageEcosystemTests
{
[Theory]
[InlineData(PackageEcosystem.Deb)]
[InlineData(PackageEcosystem.Rpm)]
[InlineData(PackageEcosystem.Apk)]
[InlineData(PackageEcosystem.Unknown)]
public void PackageEcosystem_AllValues_AreDefined(PackageEcosystem ecosystem)
{
Assert.True(Enum.IsDefined(ecosystem));
}
[Fact]
public void PackageEcosystem_AllValues_AreCounted()
{
var values = Enum.GetValues<PackageEcosystem>();
Assert.Equal(4, values.Length);
}
}
/// <summary>
/// Tests for ProductContext record.
/// </summary>
public sealed class ProductContextTests
{
[Fact]
public void ProductContext_RequiredProperties_MustBeSet()
{
var context = new ProductContext(
Distro: "debian",
Release: "bookworm",
RepoScope: "main",
Architecture: "amd64");
Assert.Equal("debian", context.Distro);
Assert.Equal("bookworm", context.Release);
Assert.Equal("main", context.RepoScope);
Assert.Equal("amd64", context.Architecture);
}
[Fact]
public void ProductContext_OptionalProperties_CanBeNull()
{
var context = new ProductContext(
Distro: "alpine",
Release: "3.19",
RepoScope: null,
Architecture: null);
Assert.Null(context.RepoScope);
Assert.Null(context.Architecture);
}
[Fact]
public void ProductContext_RecordEquality_WorksCorrectly()
{
var c1 = new ProductContext("rhel", "9", "main", "x86_64");
var c2 = new ProductContext("rhel", "9", "main", "x86_64");
Assert.Equal(c1, c2);
}
}
/// <summary>
/// Tests for PackageKey record.
/// </summary>
public sealed class PackageKeyTests
{
[Fact]
public void PackageKey_RequiredProperties_MustBeSet()
{
var key = new PackageKey(
Ecosystem: PackageEcosystem.Deb,
PackageName: "nginx",
SourcePackageName: "nginx");
Assert.Equal(PackageEcosystem.Deb, key.Ecosystem);
Assert.Equal("nginx", key.PackageName);
Assert.Equal("nginx", key.SourcePackageName);
}
[Fact]
public void PackageKey_SourcePackage_CanBeNull()
{
var key = new PackageKey(
Ecosystem: PackageEcosystem.Rpm,
PackageName: "httpd",
SourcePackageName: null);
Assert.Null(key.SourcePackageName);
}
}
/// <summary>
/// Tests for EvidenceTier enum.
/// </summary>
public sealed class EvidenceTierTests
{
[Theory]
[InlineData(EvidenceTier.Unknown, 0)]
[InlineData(EvidenceTier.NvdRange, 5)]
[InlineData(EvidenceTier.UpstreamCommit, 4)]
[InlineData(EvidenceTier.SourcePatch, 3)]
[InlineData(EvidenceTier.Changelog, 2)]
[InlineData(EvidenceTier.DistroOval, 1)]
public void EvidenceTier_Values_HaveCorrectNumericValue(EvidenceTier tier, int expected)
{
Assert.Equal(expected, (int)tier);
}
[Fact]
public void EvidenceTier_DistroOval_IsHighestConfidence()
{
// Tier 1 is highest confidence (lowest numeric value)
var allTiers = Enum.GetValues<EvidenceTier>().Where(t => t != EvidenceTier.Unknown);
var highestConfidence = allTiers.OrderBy(t => (int)t).First();
Assert.Equal(EvidenceTier.DistroOval, highestConfidence);
}
[Fact]
public void EvidenceTier_NvdRange_IsLowestConfidence()
{
// Tier 5 is lowest confidence (highest numeric value)
var allTiers = Enum.GetValues<EvidenceTier>().Where(t => t != EvidenceTier.Unknown);
var lowestConfidence = allTiers.OrderByDescending(t => (int)t).First();
Assert.Equal(EvidenceTier.NvdRange, lowestConfidence);
}
}
/// <summary>
/// Tests for FixStatus enum.
/// </summary>
public sealed class FixStatusTests
{
[Theory]
[InlineData(FixStatus.Patched)]
[InlineData(FixStatus.Vulnerable)]
[InlineData(FixStatus.NotAffected)]
[InlineData(FixStatus.WontFix)]
[InlineData(FixStatus.UnderInvestigation)]
[InlineData(FixStatus.Unknown)]
public void FixStatus_AllValues_AreDefined(FixStatus status)
{
Assert.True(Enum.IsDefined(status));
}
[Fact]
public void FixStatus_AllValues_AreCounted()
{
var values = Enum.GetValues<FixStatus>();
Assert.Equal(6, values.Length);
}
}
/// <summary>
/// Tests for RulePriority enum.
/// </summary>
public sealed class RulePriorityTests
{
[Fact]
public void RulePriority_DistroNativeOval_IsHighestPriority()
{
var allPriorities = Enum.GetValues<RulePriority>();
var highest = allPriorities.Max(p => (int)p);
Assert.Equal((int)RulePriority.DistroNativeOval, highest);
Assert.Equal(100, highest);
}
[Fact]
public void RulePriority_NvdRangeHeuristic_IsLowestPriority()
{
var allPriorities = Enum.GetValues<RulePriority>();
var lowest = allPriorities.Min(p => (int)p);
Assert.Equal((int)RulePriority.NvdRangeHeuristic, lowest);
Assert.Equal(20, lowest);
}
[Fact]
public void RulePriority_LegacyAliases_MatchNewValues()
{
Assert.Equal(RulePriority.DistroNativeOval, RulePriority.DistroNative);
Assert.Equal(RulePriority.ChangelogExplicitCve, RulePriority.VendorCsaf);
Assert.Equal(RulePriority.NvdRangeHeuristic, RulePriority.ThirdParty);
}
[Theory]
[InlineData(RulePriority.NvdRangeHeuristic, 20)]
[InlineData(RulePriority.UpstreamCommitPartialMatch, 45)]
[InlineData(RulePriority.UpstreamCommitExactParity, 55)]
[InlineData(RulePriority.SourcePatchFuzzyMatch, 60)]
[InlineData(RulePriority.SourcePatchExactMatch, 70)]
[InlineData(RulePriority.ChangelogBugIdMapped, 75)]
[InlineData(RulePriority.ChangelogExplicitCve, 85)]
[InlineData(RulePriority.DerivativeOvalMedium, 90)]
[InlineData(RulePriority.DerivativeOvalHigh, 95)]
[InlineData(RulePriority.DistroNativeOval, 100)]
public void RulePriority_Values_HaveCorrectNumericValue(RulePriority priority, int expected)
{
Assert.Equal(expected, (int)priority);
}
}
/// <summary>
/// Tests for EvidencePointer record.
/// </summary>
public sealed class EvidencePointerTests
{
[Fact]
public void EvidencePointer_RequiredProperties_MustBeSet()
{
var fetchedAt = DateTimeOffset.UtcNow;
var pointer = new EvidencePointer(
SourceType: "debian-tracker",
SourceUrl: "https://security-tracker.debian.org/tracker/CVE-2024-0001",
SourceDigest: "sha256:abc123",
FetchedAt: fetchedAt,
TierSource: EvidenceTier.DistroOval);
Assert.Equal("debian-tracker", pointer.SourceType);
Assert.Equal("https://security-tracker.debian.org/tracker/CVE-2024-0001", pointer.SourceUrl);
Assert.Equal("sha256:abc123", pointer.SourceDigest);
Assert.Equal(fetchedAt, pointer.FetchedAt);
Assert.Equal(EvidenceTier.DistroOval, pointer.TierSource);
}
[Fact]
public void EvidencePointer_TierSource_DefaultsToUnknown()
{
var pointer = new EvidencePointer(
SourceType: "nvd",
SourceUrl: "https://nvd.nist.gov/vuln/detail/CVE-2024-0001",
SourceDigest: null,
FetchedAt: DateTimeOffset.UtcNow);
Assert.Equal(EvidenceTier.Unknown, pointer.TierSource);
}
}
/// <summary>
/// Tests for VersionRange record.
/// </summary>
public sealed class VersionRangeTests
{
[Fact]
public void VersionRange_FullRange_ContainsAllBoundaries()
{
var range = new VersionRange(
MinVersion: "1.0.0",
MinInclusive: true,
MaxVersion: "2.0.0",
MaxInclusive: false);
Assert.Equal("1.0.0", range.MinVersion);
Assert.True(range.MinInclusive);
Assert.Equal("2.0.0", range.MaxVersion);
Assert.False(range.MaxInclusive);
}
[Fact]
public void VersionRange_OpenEnded_AllowsNullBoundaries()
{
// All versions up to 2.0.0 (exclusive)
var range = new VersionRange(
MinVersion: null,
MinInclusive: false,
MaxVersion: "2.0.0",
MaxInclusive: false);
Assert.Null(range.MinVersion);
Assert.Equal("2.0.0", range.MaxVersion);
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.BackportProof\StellaOps.Concelier.BackportProof.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1
}

View File

@@ -387,6 +387,7 @@ public sealed class CanonicalMergerTests
IEnumerable<AffectedPackage>? packages = null,
IEnumerable<CvssMetric>? metrics = null,
IEnumerable<AdvisoryReference>? references = null,
IEnumerable<AdvisoryCredit>? credits = null,
IEnumerable<AdvisoryWeakness>? weaknesses = null,
string? canonicalMetricId = null)
{
@@ -407,7 +408,7 @@ public sealed class CanonicalMergerTests
severity: severity,
exploitKnown: false,
aliases: new[] { advisoryKey },
credits: Array.Empty<AdvisoryCredit>(),
credits: credits ?? Array.Empty<AdvisoryCredit>(),
references: references ?? Array.Empty<AdvisoryReference>(),
affectedPackages: packages ?? Array.Empty<AffectedPackage>(),
cvssMetrics: metrics ?? Array.Empty<CvssMetric>(),

View File

@@ -0,0 +1,550 @@
// -----------------------------------------------------------------------------
// SourceRegistryTests.cs
// Sprint: SPRINT_20260114_SOURCES_SETUP
// Task: Unit tests for Source Registry
// Description: Unit tests for the SourceRegistry implementation
// -----------------------------------------------------------------------------
using System.Net;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Moq;
using Moq.Protected;
using StellaOps.Concelier.Core.Configuration;
using StellaOps.Concelier.Core.Sources;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Sources;
[Trait("Category", "Unit")]
public sealed class SourceRegistryTests
{
private static readonly DateTimeOffset FixedNow = new(2026, 1, 14, 10, 0, 0, TimeSpan.Zero);
private readonly FakeTimeProvider _timeProvider = new(FixedNow);
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock = new();
private SourceRegistry CreateRegistry(SourcesConfiguration? configuration = null)
{
return new SourceRegistry(
_httpClientFactoryMock.Object,
NullLogger<SourceRegistry>.Instance,
_timeProvider,
configuration);
}
#region GetAllSources Tests
[Fact]
public void GetAllSources_ReturnsAllDefinedSources()
{
var registry = CreateRegistry();
var sources = registry.GetAllSources();
Assert.NotEmpty(sources);
Assert.True(sources.Count >= 30, "Expected at least 30 sources defined");
}
[Fact]
public void GetAllSources_ContainsExpectedSources()
{
var registry = CreateRegistry();
var sources = registry.GetAllSources();
var sourceIds = sources.Select(s => s.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.Contains("nvd", sourceIds);
Assert.Contains("ghsa", sourceIds);
Assert.Contains("osv", sourceIds);
Assert.Contains("epss", sourceIds);
Assert.Contains("kev", sourceIds);
}
#endregion
#region GetSource Tests
[Fact]
public void GetSource_ReturnsSource_ForValidId()
{
var registry = CreateRegistry();
var source = registry.GetSource("nvd");
Assert.NotNull(source);
Assert.Equal("nvd", source.Id);
Assert.Contains("NVD", source.DisplayName);
}
[Fact]
public void GetSource_IsCaseInsensitive()
{
var registry = CreateRegistry();
var source1 = registry.GetSource("NVD");
var source2 = registry.GetSource("nvd");
var source3 = registry.GetSource("Nvd");
Assert.NotNull(source1);
Assert.NotNull(source2);
Assert.NotNull(source3);
Assert.Equal(source1.Id, source2.Id);
Assert.Equal(source2.Id, source3.Id);
}
[Fact]
public void GetSource_ReturnsNull_ForUnknownSource()
{
var registry = CreateRegistry();
var source = registry.GetSource("unknown-source-xyz");
Assert.Null(source);
}
[Fact]
public void GetSource_ThrowsArgumentException_ForNullOrEmpty()
{
var registry = CreateRegistry();
// ArgumentNullException for null, ArgumentException for empty/whitespace
Assert.ThrowsAny<ArgumentException>(() => registry.GetSource(null!));
Assert.ThrowsAny<ArgumentException>(() => registry.GetSource(""));
Assert.ThrowsAny<ArgumentException>(() => registry.GetSource(" "));
}
#endregion
#region GetSourcesByCategory Tests
[Fact]
public void GetSourcesByCategory_ReturnsSourcesInCategory()
{
var registry = CreateRegistry();
var primarySources = registry.GetSourcesByCategory(SourceCategory.Primary);
Assert.NotEmpty(primarySources);
Assert.All(primarySources, s => Assert.Equal(SourceCategory.Primary, s.Category));
}
[Fact]
public void GetSourcesByCategory_ReturnsEmptyList_ForEmptyCategory()
{
var registry = CreateRegistry();
var sources = registry.GetSourcesByCategory(SourceCategory.Other);
// Other category may be empty or contain sources
Assert.NotNull(sources);
}
#endregion
#region EnableSourceAsync/DisableSourceAsync Tests
[Fact]
public async Task EnableSourceAsync_EnablesSource_ReturnsTrue()
{
var registry = CreateRegistry();
await registry.DisableSourceAsync("nvd", TestContext.Current.CancellationToken);
var result = await registry.EnableSourceAsync("nvd", TestContext.Current.CancellationToken);
Assert.True(result);
Assert.True(registry.IsEnabled("nvd"));
}
[Fact]
public async Task EnableSourceAsync_ReturnsFalse_ForUnknownSource()
{
var registry = CreateRegistry();
var result = await registry.EnableSourceAsync("unknown-source-xyz", TestContext.Current.CancellationToken);
Assert.False(result);
}
[Fact]
public async Task DisableSourceAsync_DisablesSource_ReturnsTrue()
{
var registry = CreateRegistry();
await registry.EnableSourceAsync("nvd", TestContext.Current.CancellationToken);
var result = await registry.DisableSourceAsync("nvd", TestContext.Current.CancellationToken);
Assert.True(result);
Assert.False(registry.IsEnabled("nvd"));
}
[Fact]
public async Task DisableSourceAsync_ReturnsFalse_ForUnknownSource()
{
var registry = CreateRegistry();
var result = await registry.DisableSourceAsync("unknown-source-xyz", TestContext.Current.CancellationToken);
Assert.False(result);
}
#endregion
#region GetEnabledSourcesAsync Tests
[Fact]
public async Task GetEnabledSourcesAsync_ReturnsEnabledSources()
{
var registry = CreateRegistry();
await registry.EnableSourceAsync("nvd", TestContext.Current.CancellationToken);
await registry.EnableSourceAsync("ghsa", TestContext.Current.CancellationToken);
await registry.DisableSourceAsync("osv", TestContext.Current.CancellationToken);
var enabled = await registry.GetEnabledSourcesAsync(TestContext.Current.CancellationToken);
Assert.Contains("nvd", enabled, StringComparer.OrdinalIgnoreCase);
Assert.Contains("ghsa", enabled, StringComparer.OrdinalIgnoreCase);
}
#endregion
#region IsEnabled Tests
[Fact]
public void IsEnabled_ReturnsTrue_ForEnabledSource()
{
var registry = CreateRegistry();
// By default, most sources should be enabled
var isEnabled = registry.IsEnabled("nvd");
// NVD is enabled by default
Assert.True(isEnabled);
}
[Fact]
public void IsEnabled_ReturnsFalse_ForUnknownSource()
{
var registry = CreateRegistry();
var isEnabled = registry.IsEnabled("unknown-source-xyz");
Assert.False(isEnabled);
}
#endregion
#region CheckConnectivityAsync Tests
[Fact]
public async Task CheckConnectivityAsync_ReturnsNotFound_ForUnknownSource()
{
var registry = CreateRegistry();
var result = await registry.CheckConnectivityAsync("unknown-source-xyz", TestContext.Current.CancellationToken);
Assert.Equal(SourceConnectivityStatus.Failed, result.Status);
Assert.Equal("SOURCE_NOT_FOUND", result.ErrorCode);
Assert.False(result.IsHealthy);
}
[Fact]
public async Task CheckConnectivityAsync_ReturnsHealthy_ForSuccessfulResponse()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var httpClient = new HttpClient(handlerMock.Object);
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var registry = CreateRegistry();
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
Assert.Equal(SourceConnectivityStatus.Healthy, result.Status);
Assert.True(result.IsHealthy);
Assert.NotNull(result.Latency);
Assert.Equal(FixedNow, result.CheckedAt);
}
[Fact]
public async Task CheckConnectivityAsync_ReturnsFailed_ForHttpError()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.Unauthorized));
var httpClient = new HttpClient(handlerMock.Object);
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var registry = CreateRegistry();
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
Assert.Equal(SourceConnectivityStatus.Failed, result.Status);
Assert.False(result.IsHealthy);
Assert.Equal(401, result.HttpStatusCode);
Assert.NotEmpty(result.PossibleReasons);
Assert.NotEmpty(result.RemediationSteps);
}
[Fact]
public async Task CheckConnectivityAsync_ReturnsFailed_ForNetworkError()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection refused"));
var httpClient = new HttpClient(handlerMock.Object);
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var registry = CreateRegistry();
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
Assert.Equal(SourceConnectivityStatus.Failed, result.Status);
Assert.False(result.IsHealthy);
Assert.NotNull(result.ErrorMessage);
}
[Fact]
public async Task CheckConnectivityAsync_UsesTimeProvider()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var httpClient = new HttpClient(handlerMock.Object);
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var registry = CreateRegistry();
var result = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
Assert.Equal(FixedNow, result.CheckedAt);
}
[Fact]
public async Task CheckConnectivityAsync_StoresLastResult()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var httpClient = new HttpClient(handlerMock.Object);
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var registry = CreateRegistry();
await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
var lastResult = registry.GetLastCheckResult("nvd");
Assert.NotNull(lastResult);
Assert.Equal("nvd", lastResult.SourceId);
Assert.Equal(SourceConnectivityStatus.Healthy, lastResult.Status);
}
#endregion
#region CheckAllAndAutoConfigureAsync Tests
[Fact]
public async Task CheckAllAndAutoConfigureAsync_ChecksAllSources()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Return a new HttpClient for each CreateClient call to avoid reuse issues
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(() => new HttpClient(handlerMock.Object));
var registry = CreateRegistry();
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
Assert.NotEmpty(result.Results);
Assert.Equal(registry.GetAllSources().Count, result.TotalChecked);
Assert.True(result.AllHealthy);
}
[Fact]
public async Task CheckAllAndAutoConfigureAsync_ReturnsAggregatedResult()
{
var callCount = 0;
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
Interlocked.Increment(ref callCount);
// Make some requests fail
return callCount % 5 == 0
? new HttpResponseMessage(HttpStatusCode.InternalServerError)
: new HttpResponseMessage(HttpStatusCode.OK);
});
// Return a new HttpClient for each CreateClient call to avoid reuse issues
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(() => new HttpClient(handlerMock.Object));
var registry = CreateRegistry();
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
Assert.NotEmpty(result.Results);
Assert.True(result.HealthyCount > 0);
Assert.True(result.FailedCount > 0);
Assert.False(result.AllHealthy);
Assert.True(result.HasFailures);
}
[Fact]
public async Task CheckAllAndAutoConfigureAsync_AutoEnablesHealthySources()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Return a new HttpClient for each CreateClient call to avoid reuse issues
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(() => new HttpClient(handlerMock.Object));
var configuration = new SourcesConfiguration { AutoEnableHealthySources = true };
var registry = CreateRegistry(configuration);
// Disable all sources first
foreach (var source in registry.GetAllSources())
{
await registry.DisableSourceAsync(source.Id, TestContext.Current.CancellationToken);
}
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
// All healthy sources should now be enabled
var enabled = await registry.GetEnabledSourcesAsync(TestContext.Current.CancellationToken);
Assert.Equal(result.HealthyCount, enabled.Length);
}
[Fact]
public async Task CheckAllAndAutoConfigureAsync_RecordsDuration()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Return a new HttpClient for each CreateClient call to avoid reuse issues
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(() => new HttpClient(handlerMock.Object));
var registry = CreateRegistry();
var result = await registry.CheckAllAndAutoConfigureAsync(TestContext.Current.CancellationToken);
Assert.True(result.TotalDuration >= TimeSpan.Zero);
Assert.Equal(FixedNow, result.CheckedAt);
}
#endregion
#region CheckMultipleAsync Tests
[Fact]
public async Task CheckMultipleAsync_ChecksSpecifiedSources()
{
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Return a new HttpClient for each CreateClient call to avoid reuse issues
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(() => new HttpClient(handlerMock.Object));
var registry = CreateRegistry();
var sourceIds = new[] { "nvd", "ghsa", "osv" };
var results = await registry.CheckMultipleAsync(sourceIds, TestContext.Current.CancellationToken);
Assert.Equal(3, results.Length);
Assert.All(results, r => Assert.True(r.IsHealthy));
}
#endregion
#region RetryCheckAsync Tests
[Fact]
public async Task RetryCheckAsync_RetriesConnectivityCheck()
{
var callCount = 0;
var handlerMock = new Mock<HttpMessageHandler>();
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
Interlocked.Increment(ref callCount);
// Fail first time, succeed second time
return callCount == 1
? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
: new HttpResponseMessage(HttpStatusCode.OK);
});
// Return a new HttpClient for each CreateClient call to avoid reuse issues
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(() => new HttpClient(handlerMock.Object));
var registry = CreateRegistry();
// First check fails
var firstResult = await registry.CheckConnectivityAsync("nvd", TestContext.Current.CancellationToken);
Assert.False(firstResult.IsHealthy);
// Retry succeeds
var retryResult = await registry.RetryCheckAsync("nvd", TestContext.Current.CancellationToken);
Assert.True(retryResult.IsHealthy);
}
#endregion
}

View File

@@ -0,0 +1,290 @@
using StellaOps.Attestor.ProofChain.Models;
using System.Text.Json;
using Xunit;
namespace StellaOps.Concelier.ProofService.Tests;
/// <summary>
/// Tests for ProofEvidence and related models used by BackportProofService.
/// </summary>
public sealed class ProofEvidenceModelTests
{
[Fact]
public void ProofEvidence_RequiredProperties_MustBeSet()
{
var dataJson = JsonSerializer.SerializeToElement(new { cve = "CVE-2026-0001", severity = "HIGH" });
var evidence = new ProofEvidence
{
EvidenceId = "evidence:distro:debian:DSA-1234",
Type = EvidenceType.DistroAdvisory,
Source = "debian",
Timestamp = DateTimeOffset.UtcNow,
Data = dataJson,
DataHash = "sha256:abc123def456"
};
Assert.Equal("evidence:distro:debian:DSA-1234", evidence.EvidenceId);
Assert.Equal(EvidenceType.DistroAdvisory, evidence.Type);
Assert.Equal("debian", evidence.Source);
Assert.Equal("sha256:abc123def456", evidence.DataHash);
}
[Theory]
[InlineData(EvidenceType.DistroAdvisory)]
[InlineData(EvidenceType.ChangelogMention)]
[InlineData(EvidenceType.PatchHeader)]
[InlineData(EvidenceType.BinaryFingerprint)]
[InlineData(EvidenceType.VersionComparison)]
[InlineData(EvidenceType.BuildCatalog)]
public void ProofEvidence_Type_AllValues_AreValid(EvidenceType type)
{
var dataJson = JsonSerializer.SerializeToElement(new { test = true });
var evidence = new ProofEvidence
{
EvidenceId = $"evidence:{type.ToString().ToLowerInvariant()}:test",
Type = type,
Source = "test-source",
Timestamp = DateTimeOffset.UtcNow,
Data = dataJson,
DataHash = "sha256:test"
};
Assert.Equal(type, evidence.Type);
}
[Fact]
public void ProofEvidence_DataJson_ContainsStructuredData()
{
var advisoryData = new
{
distro = "ubuntu",
advisory_id = "USN-1234-1",
packages = new[] { "libcurl4", "curl" },
fixed_version = "7.68.0-1ubuntu2.15"
};
var dataJson = JsonSerializer.SerializeToElement(advisoryData);
var evidence = new ProofEvidence
{
EvidenceId = "evidence:distro:ubuntu:USN-1234-1",
Type = EvidenceType.DistroAdvisory,
Source = "ubuntu",
Timestamp = DateTimeOffset.UtcNow,
Data = dataJson,
DataHash = "sha256:structured123"
};
Assert.Equal(JsonValueKind.Object, evidence.Data.ValueKind);
Assert.Equal("ubuntu", evidence.Data.GetProperty("distro").GetString());
}
[Fact]
public void ProofEvidence_RecordEquality_WorksCorrectly()
{
var timestamp = DateTimeOffset.UtcNow;
var dataJson = JsonSerializer.SerializeToElement(new { key = "value" });
var evidence1 = new ProofEvidence
{
EvidenceId = "evidence:test:eq",
Type = EvidenceType.ChangelogMention,
Source = "changelog",
Timestamp = timestamp,
Data = dataJson,
DataHash = "sha256:equal"
};
var evidence2 = new ProofEvidence
{
EvidenceId = "evidence:test:eq",
Type = EvidenceType.ChangelogMention,
Source = "changelog",
Timestamp = timestamp,
Data = dataJson,
DataHash = "sha256:equal"
};
Assert.Equal(evidence1, evidence2);
}
}
/// <summary>
/// Tests for ProofBlob model.
/// </summary>
public sealed class ProofBlobModelTests
{
[Fact]
public void ProofBlob_RequiredProperties_MustBeSet()
{
var evidences = new List<ProofEvidence>
{
new()
{
EvidenceId = "evidence:test:001",
Type = EvidenceType.DistroAdvisory,
Source = "debian",
Timestamp = DateTimeOffset.UtcNow,
Data = JsonSerializer.SerializeToElement(new { }),
DataHash = "sha256:ev1"
}
};
var proof = new ProofBlob
{
ProofId = "sha256:proof123",
SubjectId = "CVE-2026-0001:pkg:deb/debian/curl@7.64.0",
Type = ProofBlobType.BackportFixed,
CreatedAt = DateTimeOffset.UtcNow,
Evidences = evidences,
Method = "distro_advisory",
Confidence = 0.95,
ToolVersion = "1.0.0",
SnapshotId = "snapshot:2026-01-14"
};
Assert.Equal("sha256:proof123", proof.ProofId);
Assert.Equal("CVE-2026-0001:pkg:deb/debian/curl@7.64.0", proof.SubjectId);
Assert.Equal(ProofBlobType.BackportFixed, proof.Type);
Assert.Equal(0.95, proof.Confidence);
}
[Fact]
public void ProofBlob_WithMultipleEvidences_ContainsAll()
{
var dataJson = JsonSerializer.SerializeToElement(new { });
var evidences = new List<ProofEvidence>
{
new()
{
EvidenceId = "evidence:distro:dsa",
Type = EvidenceType.DistroAdvisory,
Source = "debian",
Timestamp = DateTimeOffset.UtcNow,
Data = dataJson,
DataHash = "sha256:dsa"
},
new()
{
EvidenceId = "evidence:changelog:debian",
Type = EvidenceType.ChangelogMention,
Source = "debian-changelog",
Timestamp = DateTimeOffset.UtcNow,
Data = dataJson,
DataHash = "sha256:changelog"
},
new()
{
EvidenceId = "evidence:patch:fix",
Type = EvidenceType.PatchHeader,
Source = "git-patch",
Timestamp = DateTimeOffset.UtcNow,
Data = dataJson,
DataHash = "sha256:patch"
}
};
var proof = new ProofBlob
{
ProofId = "sha256:multiproof",
SubjectId = "CVE-2026-0002:pkg:npm/lodash@4.17.20",
Type = ProofBlobType.BackportFixed,
CreatedAt = DateTimeOffset.UtcNow,
Evidences = evidences,
Method = "combined",
Confidence = 0.92,
ToolVersion = "1.0.0",
SnapshotId = "snapshot:2026-01-14"
};
Assert.Equal(3, proof.Evidences.Count);
Assert.Contains(proof.Evidences, e => e.Type == EvidenceType.DistroAdvisory);
Assert.Contains(proof.Evidences, e => e.Type == EvidenceType.ChangelogMention);
Assert.Contains(proof.Evidences, e => e.Type == EvidenceType.PatchHeader);
}
[Theory]
[InlineData(ProofBlobType.BackportFixed)]
[InlineData(ProofBlobType.NotAffected)]
[InlineData(ProofBlobType.Vulnerable)]
[InlineData(ProofBlobType.Unknown)]
public void ProofBlob_Type_AllValues_AreValid(ProofBlobType type)
{
var proof = new ProofBlob
{
ProofId = $"sha256:{type.ToString().ToLowerInvariant()}",
SubjectId = "CVE-2026-TYPE:pkg:test/pkg@1.0.0",
Type = type,
CreatedAt = DateTimeOffset.UtcNow,
Evidences = Array.Empty<ProofEvidence>(),
Method = "test",
Confidence = 0.5,
ToolVersion = "1.0.0",
SnapshotId = "snapshot:test"
};
Assert.Equal(type, proof.Type);
}
[Fact]
public void ProofBlob_Confidence_InValidRange()
{
var proof = new ProofBlob
{
ProofId = "sha256:conf",
SubjectId = "CVE-2026-CONF:pkg:test/pkg@1.0.0",
Type = ProofBlobType.BackportFixed,
CreatedAt = DateTimeOffset.UtcNow,
Evidences = Array.Empty<ProofEvidence>(),
Method = "test",
Confidence = 0.87,
ToolVersion = "1.0.0",
SnapshotId = "snapshot:test"
};
Assert.InRange(proof.Confidence, 0.0, 1.0);
}
[Fact]
public void ProofBlob_ProofHash_IsOptional()
{
var proofWithoutHash = new ProofBlob
{
ProofId = "sha256:nohash",
SubjectId = "CVE-2026-NH:pkg:test/pkg@1.0.0",
Type = ProofBlobType.Unknown,
CreatedAt = DateTimeOffset.UtcNow,
Evidences = Array.Empty<ProofEvidence>(),
Method = "test",
Confidence = 0.0,
ToolVersion = "1.0.0",
SnapshotId = "snapshot:test"
};
Assert.Null(proofWithoutHash.ProofHash);
var proofWithHash = proofWithoutHash with { ProofHash = "sha256:computed" };
Assert.Equal("sha256:computed", proofWithHash.ProofHash);
}
[Fact]
public void ProofBlob_SubjectId_ContainsCveAndPurl()
{
var proof = new ProofBlob
{
ProofId = "sha256:subject",
SubjectId = "CVE-2026-12345:pkg:pypi/django@4.2.0",
Type = ProofBlobType.NotAffected,
CreatedAt = DateTimeOffset.UtcNow,
Evidences = Array.Empty<ProofEvidence>(),
Method = "vex",
Confidence = 1.0,
ToolVersion = "1.0.0",
SnapshotId = "snapshot:test"
};
Assert.Contains("CVE-2026-12345", proof.SubjectId);
Assert.Contains("pkg:pypi/django@4.2.0", proof.SubjectId);
}
}

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Concelier.ProofService\StellaOps.Concelier.ProofService.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1
}

View File

@@ -0,0 +1,317 @@
using StellaOps.Cryptography;
using StellaOps.Cryptography.Models;
using Xunit;
namespace StellaOps.Cryptography.Tests;
/// <summary>
/// Tests for SignatureProfile enum.
/// </summary>
public sealed class SignatureProfileTests
{
[Theory]
[InlineData(SignatureProfile.EdDsa)]
[InlineData(SignatureProfile.EcdsaP256)]
[InlineData(SignatureProfile.RsaPss)]
[InlineData(SignatureProfile.Gost2012)]
[InlineData(SignatureProfile.SM2)]
[InlineData(SignatureProfile.Eidas)]
public void SignatureProfile_AllStandardValues_AreValid(SignatureProfile profile)
{
Assert.True(Enum.IsDefined(profile));
}
[Fact]
public void SignatureProfile_EdDsa_IsDefault()
{
// EdDsa should be 0 - the default/baseline profile
Assert.Equal(0, (int)SignatureProfile.EdDsa);
}
[Fact]
public void SignatureProfile_AllValues_AreCounted()
{
var values = Enum.GetValues<SignatureProfile>();
Assert.True(values.Length >= 6, "Should have at least 6 standard profiles");
}
}
/// <summary>
/// Tests for Signature record.
/// </summary>
public sealed class SignatureTests
{
[Fact]
public void Signature_RequiredProperties_MustBeSet()
{
var signatureBytes = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var signature = new Signature
{
KeyId = "stella-ed25519-2024",
Profile = SignatureProfile.EdDsa,
Algorithm = "Ed25519",
SignatureBytes = signatureBytes
};
Assert.Equal("stella-ed25519-2024", signature.KeyId);
Assert.Equal(SignatureProfile.EdDsa, signature.Profile);
Assert.Equal("Ed25519", signature.Algorithm);
Assert.Equal(signatureBytes, signature.SignatureBytes);
}
[Fact]
public void Signature_WithTimestamp_ContainsValue()
{
var signedAt = DateTimeOffset.UtcNow;
var signature = new Signature
{
KeyId = "key-001",
Profile = SignatureProfile.EcdsaP256,
Algorithm = "ES256",
SignatureBytes = new byte[64],
SignedAt = signedAt
};
Assert.Equal(signedAt, signature.SignedAt);
}
[Fact]
public void Signature_WithCertificateChain_ContainsValue()
{
var certChain = new byte[] { 0x30, 0x82, 0x01, 0x00 }; // Mock DER cert header
var signature = new Signature
{
KeyId = "eidas-key",
Profile = SignatureProfile.Eidas,
Algorithm = "RS256",
SignatureBytes = new byte[256],
CertificateChain = certChain
};
Assert.NotNull(signature.CertificateChain);
Assert.Equal(certChain, signature.CertificateChain);
}
[Fact]
public void Signature_WithTimestampToken_ContainsValue()
{
var timestampToken = new byte[] { 0x30, 0x82, 0x02, 0x00 }; // Mock RFC 3161 token
var signature = new Signature
{
KeyId = "tsa-key",
Profile = SignatureProfile.RsaPss,
Algorithm = "PS256",
SignatureBytes = new byte[256],
TimestampToken = timestampToken
};
Assert.NotNull(signature.TimestampToken);
}
[Fact]
public void Signature_WithPublicKey_ContainsValue()
{
var publicKey = new byte[32]; // Ed25519 public key size
var signature = new Signature
{
KeyId = "ephemeral-ed25519",
Profile = SignatureProfile.EdDsa,
Algorithm = "Ed25519",
SignatureBytes = new byte[64],
PublicKey = publicKey
};
Assert.NotNull(signature.PublicKey);
Assert.Equal(32, signature.PublicKey.Length);
}
[Fact]
public void Signature_OptionalProperties_AreNullByDefault()
{
var signature = new Signature
{
KeyId = "key",
Profile = SignatureProfile.EdDsa,
Algorithm = "Ed25519",
SignatureBytes = new byte[64]
};
Assert.Null(signature.CertificateChain);
Assert.Null(signature.TimestampToken);
Assert.Null(signature.PublicKey);
}
}
/// <summary>
/// Tests for SignatureResult record.
/// </summary>
public sealed class SignatureResultTests
{
[Fact]
public void SignatureResult_RequiredProperties_MustBeSet()
{
var signedAt = DateTimeOffset.UtcNow;
var result = new SignatureResult
{
KeyId = "stella-ed25519-2024",
Profile = SignatureProfile.EdDsa,
Algorithm = "Ed25519",
Signature = new byte[64],
SignedAt = signedAt
};
Assert.Equal("stella-ed25519-2024", result.KeyId);
Assert.Equal(SignatureProfile.EdDsa, result.Profile);
Assert.Equal("Ed25519", result.Algorithm);
Assert.Equal(signedAt, result.SignedAt);
}
[Fact]
public void SignatureResult_WithMetadata_ContainsValues()
{
var metadata = new Dictionary<string, object>
{
["kms_request_id"] = "req-12345",
["certificate_serial"] = "ABC123"
};
var result = new SignatureResult
{
KeyId = "kms-key",
Profile = SignatureProfile.RsaPss,
Algorithm = "PS256",
Signature = new byte[256],
SignedAt = DateTimeOffset.UtcNow,
Metadata = metadata
};
Assert.NotNull(result.Metadata);
Assert.Equal("req-12345", result.Metadata["kms_request_id"]);
}
[Theory]
[InlineData("Ed25519", SignatureProfile.EdDsa)]
[InlineData("ES256", SignatureProfile.EcdsaP256)]
[InlineData("PS256", SignatureProfile.RsaPss)]
[InlineData("GOST3410-2012-256", SignatureProfile.Gost2012)]
[InlineData("SM2DSA", SignatureProfile.SM2)]
public void SignatureResult_ProfileAlgorithmPairs_AreValid(string algorithm, SignatureProfile profile)
{
var result = new SignatureResult
{
KeyId = $"key-{profile}",
Profile = profile,
Algorithm = algorithm,
Signature = new byte[64],
SignedAt = DateTimeOffset.UtcNow
};
Assert.Equal(algorithm, result.Algorithm);
Assert.Equal(profile, result.Profile);
}
}
/// <summary>
/// Tests for VerificationResult record.
/// </summary>
public sealed class VerificationResultTests
{
[Fact]
public void VerificationResult_Valid_HasNoFailureReason()
{
var result = new VerificationResult
{
IsValid = true,
Profile = SignatureProfile.EdDsa,
Algorithm = "Ed25519",
KeyId = "key-001"
};
Assert.True(result.IsValid);
Assert.Null(result.FailureReason);
}
[Fact]
public void VerificationResult_Invalid_HasFailureReason()
{
var result = new VerificationResult
{
IsValid = false,
Profile = SignatureProfile.EdDsa,
Algorithm = "Ed25519",
KeyId = "key-001",
FailureReason = "Signature mismatch"
};
Assert.False(result.IsValid);
Assert.Equal("Signature mismatch", result.FailureReason);
}
[Fact]
public void VerificationResult_WithCertificateValidation_ContainsValue()
{
var certValidation = new CertificateValidationResult
{
IsValid = true,
ValidFrom = DateTimeOffset.UtcNow.AddYears(-1),
ValidTo = DateTimeOffset.UtcNow.AddYears(1),
CertificateThumbprints = new[] { "ABC123", "DEF456" }
};
var result = new VerificationResult
{
IsValid = true,
Profile = SignatureProfile.Eidas,
Algorithm = "RS256",
KeyId = "eidas-key",
CertificateValidation = certValidation
};
Assert.NotNull(result.CertificateValidation);
Assert.True(result.CertificateValidation.IsValid);
Assert.Equal(2, result.CertificateValidation.CertificateThumbprints!.Count);
}
}
/// <summary>
/// Tests for CertificateValidationResult record.
/// </summary>
public sealed class CertificateValidationResultTests
{
[Fact]
public void CertificateValidationResult_Valid_HasValidityPeriod()
{
var validFrom = DateTimeOffset.UtcNow.AddYears(-1);
var validTo = DateTimeOffset.UtcNow.AddYears(2);
var result = new CertificateValidationResult
{
IsValid = true,
ValidFrom = validFrom,
ValidTo = validTo
};
Assert.True(result.IsValid);
Assert.Equal(validFrom, result.ValidFrom);
Assert.Equal(validTo, result.ValidTo);
}
[Fact]
public void CertificateValidationResult_Invalid_HasFailureReason()
{
var result = new CertificateValidationResult
{
IsValid = false,
FailureReason = "Certificate expired"
};
Assert.False(result.IsValid);
Assert.Equal("Certificate expired", result.FailureReason);
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1
}

View File

@@ -1,5 +1,6 @@
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Doctor.Models;
using StellaOps.Doctor.Plugin.Observability.Checks;

View File

@@ -9,7 +9,7 @@ using StellaOps.Excititor.Worker.Options;
namespace StellaOps.Excititor.Worker.Scheduling;
internal sealed class VexWorkerHostedService : BackgroundService
internal class VexWorkerHostedService : BackgroundService
{
private readonly IOptions<VexWorkerOptions> _options;
private readonly IVexProviderRunner _runner;

View File

@@ -1,6 +1,6 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.TimeProvider.Testing;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
using StellaOps.Excititor.Core.Evidence;
using StellaOps.TestKit;
@@ -250,3 +250,4 @@ public sealed class VexEvidenceLinkerTests
});
}
}

View File

@@ -187,7 +187,8 @@ public sealed class ExportEngineTests
await engine.ExportAsync(context, CancellationToken.None);
Assert.NotNull(exporter.LastRequest);
Assert.False(exporter.LastRequest!.EvidenceLinks.IsDefaultOrEmpty);
Assert.NotNull(exporter.LastRequest!.EvidenceLinks);
Assert.False(exporter.LastRequest!.EvidenceLinks!.IsEmpty);
Assert.Contains(evidenceLinker.EntryId, exporter.LastRequest.EvidenceLinks!.Keys);
}

View File

@@ -3,6 +3,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MsOptions = Microsoft.Extensions.Options;
using StellaOps.Excititor.Worker.Options;
using StellaOps.Excititor.Worker.Scheduling;
using Xunit;
@@ -14,7 +15,7 @@ public sealed class VexWorkerHostedServiceTests
[Fact]
public async Task ExecuteAsync_NoSchedules_DoesNotInvokeRunner()
{
var options = Options.Create(new VexWorkerOptions());
var options = MsOptions.Options.Create(new VexWorkerOptions());
var runner = new RecordingRunner();
var service = new TestableVexWorkerHostedService(options, runner);
@@ -35,7 +36,7 @@ public sealed class VexWorkerHostedServiceTests
InitialDelay = TimeSpan.Zero,
});
var options = Options.Create(workerOptions);
var options = MsOptions.Options.Create(workerOptions);
var runner = new RecordingRunner();
var service = new TestableVexWorkerHostedService(options, runner);
using var cts = new CancellationTokenSource();

View File

@@ -1,7 +1,8 @@
using System;
using System;
using System.Net.Http;
using FluentAssertions;
using Microsoft.Extensions.Options;
using MsOptions = Microsoft.Extensions.Options;
using StellaOps.Excititor.Worker.Auth;
using StellaOps.Excititor.Worker.Options;
using Xunit;
@@ -19,7 +20,7 @@ public sealed class TenantAuthorityClientFactoryTests
var options = new TenantAuthorityOptions();
options.BaseUrls.Add("tenant-a", "https://authority.example/");
var factory = new TenantAuthorityClientFactory(
Options.Create(options),
MsOptions.Options.Create(options),
new StubHttpClientFactory());
using var client = factory.Create("tenant-a");
@@ -36,7 +37,7 @@ public sealed class TenantAuthorityClientFactoryTests
var options = new TenantAuthorityOptions();
options.BaseUrls.Add("tenant-a", "https://authority.example/");
var factory = new TenantAuthorityClientFactory(
Options.Create(options),
MsOptions.Options.Create(options),
new StubHttpClientFactory());
FluentActions.Invoking(() => factory.Create(string.Empty))
@@ -50,7 +51,7 @@ public sealed class TenantAuthorityClientFactoryTests
var options = new TenantAuthorityOptions();
options.BaseUrls.Add("tenant-a", "https://authority.example/");
var factory = new TenantAuthorityClientFactory(
Options.Create(options),
MsOptions.Options.Create(options),
new StubHttpClientFactory());
FluentActions.Invoking(() => factory.Create("tenant-b"))

View File

@@ -202,7 +202,8 @@ public sealed class ExportScopeResolver : IExportScopeResolver
}
// Warn about large exports
if (!scope.MaxItems.HasValue && scope.Sampling?.Strategy == SamplingStrategy.None)
if (!scope.MaxItems.HasValue &&
(scope.Sampling is null || scope.Sampling.Strategy == SamplingStrategy.None))
{
errors.Add(new ExportValidationError
{

View File

@@ -7,4 +7,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| --- | --- | --- |
| AUDIT-0333-M | DONE | Revalidated 2026-01-07; maintainability audit for ExportCenter.Core. |
| AUDIT-0333-T | DONE | Revalidated 2026-01-07; test coverage audit for ExportCenter.Core. |
| AUDIT-0333-A | TODO | Pending approval (non-test project; revalidated 2026-01-07). |
| AUDIT-0333-A | DONE | Applied 2026-01-13; determinism verified, tests added for LineageEvidencePackService/ExportPlanner/ExportScopeResolver, large export warning fix. |

View File

@@ -254,6 +254,106 @@ public sealed class ExportPlannerTests
Assert.Equal("test-user", result.Plan.InitiatedBy);
}
[Fact]
public async Task CreatePlanAsync_WithInvalidScopeJson_FallsBackToDefaultScope()
{
var tenantId = Guid.NewGuid();
var profile = await _profileRepository.CreateAsync(new ExportProfile
{
ProfileId = Guid.NewGuid(),
TenantId = tenantId,
Name = "Invalid Scope Profile",
Kind = ExportProfileKind.AdHoc,
Status = ExportProfileStatus.Active,
ScopeJson = """{ this is not valid json }"""
});
var result = await _planner.CreatePlanAsync(new ExportPlanRequest
{
ProfileId = profile.ProfileId,
TenantId = tenantId
});
// Should succeed with default scope (fallback behavior)
Assert.True(result.Success);
Assert.NotNull(result.Plan);
Assert.NotNull(result.Plan.ResolvedScope);
}
[Fact]
public async Task CreatePlanAsync_WithInvalidFormatJson_FallsBackToDefaultFormat()
{
var tenantId = Guid.NewGuid();
var profile = await _profileRepository.CreateAsync(new ExportProfile
{
ProfileId = Guid.NewGuid(),
TenantId = tenantId,
Name = "Invalid Format Profile",
Kind = ExportProfileKind.AdHoc,
Status = ExportProfileStatus.Active,
FormatJson = """{ invalid: json: here }"""
});
var result = await _planner.CreatePlanAsync(new ExportPlanRequest
{
ProfileId = profile.ProfileId,
TenantId = tenantId
});
// Should succeed with default format (fallback behavior)
Assert.True(result.Success);
Assert.NotNull(result.Plan);
Assert.NotNull(result.Plan.Format);
}
[Fact]
public async Task CreatePlanAsync_WithNullScopeJson_UsesDefaultScope()
{
var tenantId = Guid.NewGuid();
var profile = await _profileRepository.CreateAsync(new ExportProfile
{
ProfileId = Guid.NewGuid(),
TenantId = tenantId,
Name = "Null Scope Profile",
Kind = ExportProfileKind.AdHoc,
Status = ExportProfileStatus.Active,
ScopeJson = null
});
var result = await _planner.CreatePlanAsync(new ExportPlanRequest
{
ProfileId = profile.ProfileId,
TenantId = tenantId
});
Assert.True(result.Success);
Assert.NotNull(result.Plan);
}
[Fact]
public async Task CreatePlanAsync_WithEmptyScopeJson_UsesDefaultScope()
{
var tenantId = Guid.NewGuid();
var profile = await _profileRepository.CreateAsync(new ExportProfile
{
ProfileId = Guid.NewGuid(),
TenantId = tenantId,
Name = "Empty Scope Profile",
Kind = ExportProfileKind.AdHoc,
Status = ExportProfileStatus.Active,
ScopeJson = ""
});
var result = await _planner.CreatePlanAsync(new ExportPlanRequest
{
ProfileId = profile.ProfileId,
TenantId = tenantId
});
Assert.True(result.Success);
Assert.NotNull(result.Plan);
}
private async Task<ExportProfile> CreateTestProfile(Guid tenantId)
{
return await _profileRepository.CreateAsync(new ExportProfile

View File

@@ -218,4 +218,118 @@ public sealed class ExportScopeResolverTests
Assert.True(estimate.EstimatedSizeBytes > 0);
Assert.True(estimate.EstimatedProcessingTime > TimeSpan.Zero);
}
[Fact]
public async Task ResolveAsync_WithNullSeed_UsesDeterministicSeedFromScope()
{
var tenantId = Guid.NewGuid();
var scope = new ExportScope
{
Sampling = new SamplingConfig
{
Strategy = SamplingStrategy.Random,
Size = 10,
Seed = null // null seed should be derived deterministically
},
TargetKinds = ["sbom"]
};
var result1 = await _resolver.ResolveAsync(tenantId, scope);
var result2 = await _resolver.ResolveAsync(tenantId, scope);
Assert.True(result1.Success);
Assert.True(result2.Success);
Assert.NotNull(result1.SamplingMetadata);
Assert.NotNull(result2.SamplingMetadata);
Assert.Equal(result1.SamplingMetadata.Seed, result2.SamplingMetadata.Seed);
Assert.Equal(result1.Items.Count, result2.Items.Count);
// Same items in same order due to deterministic seeding
for (var i = 0; i < result1.Items.Count; i++)
{
Assert.Equal(result1.Items[i].ItemId, result2.Items[i].ItemId);
}
}
[Fact]
public async Task ResolveAsync_DifferentTenants_ProduceDifferentSeeds()
{
var tenantId1 = Guid.NewGuid();
var tenantId2 = Guid.NewGuid();
var scope = new ExportScope
{
Sampling = new SamplingConfig
{
Strategy = SamplingStrategy.Random,
Size = 10,
Seed = null
},
TargetKinds = ["sbom"]
};
var result1 = await _resolver.ResolveAsync(tenantId1, scope);
var result2 = await _resolver.ResolveAsync(tenantId2, scope);
Assert.True(result1.Success);
Assert.True(result2.Success);
Assert.NotNull(result1.SamplingMetadata);
Assert.NotNull(result2.SamplingMetadata);
// Seeds should differ due to different tenant IDs
Assert.NotEqual(result1.SamplingMetadata.Seed, result2.SamplingMetadata.Seed);
}
[Fact]
public async Task ResolveAsync_ItemIds_AreDeterministic()
{
var tenantId = Guid.NewGuid();
var scope = new ExportScope
{
SourceRefs = ["ref-001", "ref-002"]
};
var result1 = await _resolver.ResolveAsync(tenantId, scope);
var result2 = await _resolver.ResolveAsync(tenantId, scope);
Assert.True(result1.Success);
Assert.True(result2.Success);
// Item IDs should be deterministic based on tenant/sourceRef/kind
for (var i = 0; i < result1.Items.Count; i++)
{
Assert.Equal(result1.Items[i].ItemId, result2.Items[i].ItemId);
}
}
[Fact]
public async Task ResolveAsync_WithTimeProvider_UsesProvidedTime()
{
var fixedNow = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
var timeProvider = new FixedTimeProvider(fixedNow);
var resolver = new ExportScopeResolver(
NullLogger<ExportScopeResolver>.Instance,
timeProvider);
var tenantId = Guid.NewGuid();
var scope = new ExportScope
{
SourceRefs = ["test-ref"]
};
var result = await resolver.ResolveAsync(tenantId, scope);
Assert.True(result.Success);
Assert.NotEmpty(result.Items);
// Items should have timestamps based on the fixed time
Assert.All(result.Items, item => Assert.True(item.CreatedAt <= fixedNow));
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -0,0 +1,227 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Determinism;
using StellaOps.ExportCenter.Core.Domain;
using StellaOps.ExportCenter.Core.Services;
using Xunit;
namespace StellaOps.ExportCenter.Tests.Services;
/// <summary>
/// Tests for <see cref="LineageEvidencePackService"/> with focus on determinism.
/// </summary>
public sealed class LineageEvidencePackServiceTests
{
private static readonly DateTimeOffset FixedNow = new(2025, 1, 15, 10, 30, 0, TimeSpan.Zero);
[Fact]
public async Task GeneratePackAsync_UsesInjectedTimeProvider()
{
var timeProvider = new FixedTimeProvider(FixedNow);
var guidProvider = new SequentialGuidProvider(Guid.Parse("11111111-1111-1111-1111-111111111111"));
var service = new LineageEvidencePackService(
NullLogger<LineageEvidencePackService>.Instance,
timeProvider,
guidProvider);
var result = await service.GeneratePackAsync(
"sha256:abcdef1234567890",
"tenant-001",
new EvidencePackOptions { IncludeCycloneDx = true });
Assert.True(result.Success);
Assert.NotNull(result.Pack);
Assert.Equal(FixedNow, result.Pack.GeneratedAt);
// PackId is the first GUID generated by the sequential provider
Assert.StartsWith("11111111-1111-1111-1111-1111", result.Pack.PackId.ToString("D"));
}
[Fact]
public async Task GeneratePackAsync_ProducesDeterministicResults()
{
var timeProvider = new FixedTimeProvider(FixedNow);
var guidProvider1 = new SequentialGuidProvider(Guid.Parse("22222222-2222-2222-2222-222222222222"));
var guidProvider2 = new SequentialGuidProvider(Guid.Parse("22222222-2222-2222-2222-222222222222"));
var service1 = new LineageEvidencePackService(
NullLogger<LineageEvidencePackService>.Instance,
timeProvider,
guidProvider1);
var service2 = new LineageEvidencePackService(
NullLogger<LineageEvidencePackService>.Instance,
timeProvider,
guidProvider2);
var options = new EvidencePackOptions
{
IncludeCycloneDx = true,
IncludeSpdx = true,
IncludeVex = true
};
var result1 = await service1.GeneratePackAsync("sha256:abc123", "tenant-1", options);
var result2 = await service2.GeneratePackAsync("sha256:abc123", "tenant-1", options);
Assert.True(result1.Success);
Assert.True(result2.Success);
Assert.Equal(result1.Pack!.PackId, result2.Pack!.PackId);
Assert.Equal(result1.Pack.GeneratedAt, result2.Pack.GeneratedAt);
Assert.Equal(result1.Pack.Manifest?.MerkleRoot, result2.Pack.Manifest?.MerkleRoot);
}
[Fact]
public async Task GeneratePackAsync_ManifestEntriesAreSortedDeterministically()
{
var timeProvider = new FixedTimeProvider(FixedNow);
var guidProvider = new SequentialGuidProvider(Guid.Parse("33333333-3333-3333-3333-333333333333"));
var service = new LineageEvidencePackService(
NullLogger<LineageEvidencePackService>.Instance,
timeProvider,
guidProvider);
var options = new EvidencePackOptions
{
IncludeCycloneDx = true,
IncludeSpdx = true,
IncludeVex = true,
IncludeAttestations = true
};
var result = await service.GeneratePackAsync("sha256:test", "tenant-1", options);
Assert.True(result.Success);
Assert.NotNull(result.Pack?.Manifest);
// Verify entries are sorted by path
var paths = result.Pack.Manifest!.Entries.Select(e => e.Path).ToList();
var sortedPaths = paths.OrderBy(p => p, StringComparer.Ordinal).ToList();
Assert.Equal(sortedPaths, paths);
}
[Fact]
public async Task GeneratePackAsync_ReplayHashIsDeterministic()
{
var timeProvider = new FixedTimeProvider(FixedNow);
var guidProvider1 = new SequentialGuidProvider(Guid.Parse("44444444-4444-4444-4444-444444444444"));
var guidProvider2 = new SequentialGuidProvider(Guid.Parse("44444444-4444-4444-4444-444444444444"));
var service1 = new LineageEvidencePackService(
NullLogger<LineageEvidencePackService>.Instance,
timeProvider,
guidProvider1);
var service2 = new LineageEvidencePackService(
NullLogger<LineageEvidencePackService>.Instance,
timeProvider,
guidProvider2);
var result1 = await service1.GeneratePackAsync("sha256:replay123", "tenant-replay");
var result2 = await service2.GeneratePackAsync("sha256:replay123", "tenant-replay");
Assert.True(result1.Success);
Assert.True(result2.Success);
Assert.Equal(result1.Pack!.ReplayHash, result2.Pack!.ReplayHash);
Assert.NotNull(result1.Pack.ReplayHash);
}
[Fact]
public async Task GetPackAsync_ReturnsNullForNonexistentPack()
{
var service = new LineageEvidencePackService(
NullLogger<LineageEvidencePackService>.Instance);
var result = await service.GetPackAsync(Guid.NewGuid(), "tenant-1");
Assert.Null(result);
}
[Fact]
public async Task GetPackAsync_ReturnsCachedPack()
{
var timeProvider = new FixedTimeProvider(FixedNow);
var guidProvider = new SequentialGuidProvider(Guid.Parse("55555555-5555-5555-5555-555555555555"));
var service = new LineageEvidencePackService(
NullLogger<LineageEvidencePackService>.Instance,
timeProvider,
guidProvider);
var generateResult = await service.GeneratePackAsync("sha256:cached", "tenant-cache");
Assert.True(generateResult.Success);
var getResult = await service.GetPackAsync(generateResult.Pack!.PackId, "tenant-cache");
Assert.NotNull(getResult);
Assert.Equal(generateResult.Pack.PackId, getResult.PackId);
}
[Fact]
public async Task GetPackAsync_EnforcesTenantIsolation()
{
var timeProvider = new FixedTimeProvider(FixedNow);
var guidProvider = new SequentialGuidProvider(Guid.Parse("66666666-6666-6666-6666-666666666666"));
var service = new LineageEvidencePackService(
NullLogger<LineageEvidencePackService>.Instance,
timeProvider,
guidProvider);
var generateResult = await service.GeneratePackAsync("sha256:tenant-test", "tenant-a");
Assert.True(generateResult.Success);
// Try to access with different tenant
var getResult = await service.GetPackAsync(generateResult.Pack!.PackId, "tenant-b");
Assert.Null(getResult);
}
[Fact]
public async Task GeneratePackAsync_WithNoOptions_UsesDefaults()
{
var timeProvider = new FixedTimeProvider(FixedNow);
var guidProvider = new SequentialGuidProvider(Guid.Parse("77777777-7777-7777-7777-777777777777"));
var service = new LineageEvidencePackService(
NullLogger<LineageEvidencePackService>.Instance,
timeProvider,
guidProvider);
var result = await service.GeneratePackAsync("sha256:defaults", "tenant-default");
Assert.True(result.Success);
Assert.NotNull(result.Pack);
Assert.NotNull(result.Pack.Manifest);
}
[Fact]
public async Task VerifyPackAsync_ReturnsTrueForValidPack()
{
var timeProvider = new FixedTimeProvider(FixedNow);
var guidProvider = new SequentialGuidProvider(Guid.Parse("88888888-8888-8888-8888-888888888888"));
var service = new LineageEvidencePackService(
NullLogger<LineageEvidencePackService>.Instance,
timeProvider,
guidProvider);
var tenantId = "tenant-verify";
var generateResult = await service.GeneratePackAsync("sha256:verify", tenantId);
Assert.True(generateResult.Success);
var verifyResult = await service.VerifyPackAsync(generateResult.Pack!.PackId, tenantId);
Assert.True(verifyResult.Valid);
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -0,0 +1,282 @@
using StellaOps.Feedser.BinaryAnalysis.Models;
using Xunit;
namespace StellaOps.Feedser.BinaryAnalysis.Tests;
/// <summary>
/// Tests for BinaryFingerprint and related models.
/// </summary>
public sealed class BinaryFingerprintTests
{
[Fact]
public void BinaryFingerprint_RequiredProperties_MustBeSet()
{
var fingerprint = new BinaryFingerprint
{
FingerprintId = "fingerprint:tlsh:abc123",
CveId = "CVE-2026-12345",
Method = FingerprintMethod.TLSH,
FingerprintValue = "T1E8F12345678901234567890123456789012345678901234567890",
TargetBinary = "libssl.so.3",
Metadata = new FingerprintMetadata
{
Architecture = "x86_64",
Format = "ELF",
HasDebugSymbols = false
},
ExtractedAt = DateTimeOffset.UtcNow,
ExtractorVersion = "1.0.0"
};
Assert.Equal("fingerprint:tlsh:abc123", fingerprint.FingerprintId);
Assert.Equal("CVE-2026-12345", fingerprint.CveId);
Assert.Equal(FingerprintMethod.TLSH, fingerprint.Method);
Assert.Equal("libssl.so.3", fingerprint.TargetBinary);
}
[Fact]
public void BinaryFingerprint_WithOptionalFunction_ContainsValue()
{
var fingerprint = new BinaryFingerprint
{
FingerprintId = "fingerprint:cfghash:def456",
CveId = "CVE-2026-00001",
Method = FingerprintMethod.CFGHash,
FingerprintValue = "sha256:abcdef1234567890",
TargetBinary = "openssl",
TargetFunction = "SSL_read",
Metadata = new FingerprintMetadata
{
Architecture = "aarch64",
Format = "ELF",
HasDebugSymbols = true
},
ExtractedAt = DateTimeOffset.UtcNow,
ExtractorVersion = "1.0.0"
};
Assert.Equal("SSL_read", fingerprint.TargetFunction);
}
[Fact]
public void BinaryFingerprint_CveId_CanBeNull()
{
var fingerprint = new BinaryFingerprint
{
FingerprintId = "fingerprint:section:ghi789",
CveId = null,
Method = FingerprintMethod.SectionHash,
FingerprintValue = "sha256:section123",
TargetBinary = "myapp.exe",
Metadata = new FingerprintMetadata
{
Architecture = "x86_64",
Format = "PE",
HasDebugSymbols = false
},
ExtractedAt = DateTimeOffset.UtcNow,
ExtractorVersion = "2.0.0"
};
Assert.Null(fingerprint.CveId);
}
[Theory]
[InlineData(FingerprintMethod.TLSH)]
[InlineData(FingerprintMethod.CFGHash)]
[InlineData(FingerprintMethod.InstructionHash)]
[InlineData(FingerprintMethod.SymbolHash)]
[InlineData(FingerprintMethod.SectionHash)]
public void FingerprintMethod_AllValues_AreValid(FingerprintMethod method)
{
var fingerprint = new BinaryFingerprint
{
FingerprintId = $"fingerprint:{method.ToString().ToLowerInvariant()}:test",
CveId = "CVE-2026-TEST",
Method = method,
FingerprintValue = "test-value",
TargetBinary = "test.bin",
Metadata = new FingerprintMetadata
{
Architecture = "x86_64",
Format = "ELF",
HasDebugSymbols = false
},
ExtractedAt = DateTimeOffset.UtcNow,
ExtractorVersion = "1.0.0"
};
Assert.Equal(method, fingerprint.Method);
}
}
/// <summary>
/// Tests for FingerprintMetadata record.
/// </summary>
public sealed class FingerprintMetadataTests
{
[Fact]
public void FingerprintMetadata_RequiredProperties_MustBeSet()
{
var metadata = new FingerprintMetadata
{
Architecture = "x86_64",
Format = "ELF",
HasDebugSymbols = true
};
Assert.Equal("x86_64", metadata.Architecture);
Assert.Equal("ELF", metadata.Format);
Assert.True(metadata.HasDebugSymbols);
}
[Theory]
[InlineData("x86_64")]
[InlineData("aarch64")]
[InlineData("armv7")]
[InlineData("riscv64")]
[InlineData("ppc64le")]
public void FingerprintMetadata_Architecture_SupportedValues(string architecture)
{
var metadata = new FingerprintMetadata
{
Architecture = architecture,
Format = "ELF",
HasDebugSymbols = false
};
Assert.Equal(architecture, metadata.Architecture);
}
[Theory]
[InlineData("ELF")]
[InlineData("PE")]
[InlineData("Mach-O")]
public void FingerprintMetadata_Format_SupportedValues(string format)
{
var metadata = new FingerprintMetadata
{
Architecture = "x86_64",
Format = format,
HasDebugSymbols = false
};
Assert.Equal(format, metadata.Format);
}
[Fact]
public void FingerprintMetadata_WithOptionalProperties_ContainsValues()
{
var metadata = new FingerprintMetadata
{
Architecture = "x86_64",
Format = "ELF",
HasDebugSymbols = true,
Compiler = "gcc-13.2.0",
OptimizationLevel = "-O2",
FileOffset = 0x1000,
RegionSize = 4096
};
Assert.Equal("gcc-13.2.0", metadata.Compiler);
Assert.Equal("-O2", metadata.OptimizationLevel);
Assert.Equal(0x1000, metadata.FileOffset);
Assert.Equal(4096, metadata.RegionSize);
}
[Fact]
public void FingerprintMetadata_OptionalProperties_AreNullByDefault()
{
var metadata = new FingerprintMetadata
{
Architecture = "aarch64",
Format = "Mach-O",
HasDebugSymbols = false
};
Assert.Null(metadata.Compiler);
Assert.Null(metadata.OptimizationLevel);
Assert.Null(metadata.FileOffset);
Assert.Null(metadata.RegionSize);
}
}
/// <summary>
/// Tests for FingerprintMatchResult record.
/// </summary>
public sealed class FingerprintMatchResultTests
{
[Fact]
public void FingerprintMatchResult_SuccessfulMatch_ContainsData()
{
var result = new FingerprintMatchResult
{
IsMatch = true,
Similarity = 0.95,
Confidence = 0.92,
MatchedFingerprintId = "fingerprint:tlsh:matched123",
Method = FingerprintMethod.TLSH,
MatchDetails = new Dictionary<string, object>
{
["distance"] = 15,
["threshold"] = 50
}
};
Assert.True(result.IsMatch);
Assert.Equal(0.95, result.Similarity);
Assert.Equal(0.92, result.Confidence);
Assert.Equal("fingerprint:tlsh:matched123", result.MatchedFingerprintId);
}
[Fact]
public void FingerprintMatchResult_NoMatch_HasZeroSimilarity()
{
var result = new FingerprintMatchResult
{
IsMatch = false,
Similarity = 0.0,
Confidence = 0.0,
Method = FingerprintMethod.CFGHash
};
Assert.False(result.IsMatch);
Assert.Equal(0.0, result.Similarity);
Assert.Null(result.MatchedFingerprintId);
}
[Fact]
public void FingerprintMatchResult_PartialMatch_HasIntermediateSimilarity()
{
var result = new FingerprintMatchResult
{
IsMatch = false,
Similarity = 0.45,
Confidence = 0.30,
Method = FingerprintMethod.InstructionHash
};
Assert.False(result.IsMatch);
Assert.Equal(0.45, result.Similarity);
Assert.Equal(0.30, result.Confidence);
}
[Theory]
[InlineData(0.0)]
[InlineData(0.5)]
[InlineData(0.75)]
[InlineData(1.0)]
public void FingerprintMatchResult_Similarity_InValidRange(double similarity)
{
var result = new FingerprintMatchResult
{
IsMatch = similarity >= 0.8,
Similarity = similarity,
Confidence = similarity * 0.9,
Method = FingerprintMethod.SymbolHash
};
Assert.InRange(result.Similarity, 0.0, 1.0);
Assert.InRange(result.Confidence, 0.0, 1.0);
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,253 @@
using StellaOps.Graph.Core;
using Xunit;
namespace StellaOps.Graph.Core.Tests;
/// <summary>
/// Tests for ObservationState enum.
/// </summary>
public sealed class ObservationStateTests
{
[Theory]
[InlineData(ObservationState.Pending)]
[InlineData(ObservationState.Investigating)]
[InlineData(ObservationState.Affected)]
[InlineData(ObservationState.NotAffected)]
[InlineData(ObservationState.Fixed)]
[InlineData(ObservationState.Mitigated)]
[InlineData(ObservationState.Accepted)]
[InlineData(ObservationState.FalsePositive)]
public void ObservationState_AllValues_AreDefined(ObservationState state)
{
Assert.True(Enum.IsDefined(state));
}
[Fact]
public void ObservationState_AllValues_AreCounted()
{
var values = Enum.GetValues<ObservationState>();
Assert.Equal(8, values.Length);
}
}
/// <summary>
/// Tests for CveObservationNode record.
/// </summary>
public sealed class CveObservationNodeTests
{
[Fact]
public void CveObservationNode_RequiredProperties_MustBeSet()
{
var now = DateTimeOffset.UtcNow;
var node = new CveObservationNode
{
NodeId = "sha256:abc123",
CveId = "CVE-2024-0001",
Product = "pkg:deb/debian/nginx@1.18.0",
TenantId = "tenant-001",
State = ObservationState.Pending,
CreatedAt = now,
UpdatedAt = now
};
Assert.Equal("sha256:abc123", node.NodeId);
Assert.Equal("CVE-2024-0001", node.CveId);
Assert.Equal("pkg:deb/debian/nginx@1.18.0", node.Product);
Assert.Equal("tenant-001", node.TenantId);
Assert.Equal(ObservationState.Pending, node.State);
}
[Fact]
public void CveObservationNode_SchemaVersion_DefaultsTo1_0()
{
var now = DateTimeOffset.UtcNow;
var node = new CveObservationNode
{
NodeId = "sha256:xyz",
CveId = "CVE-2024-0002",
Product = "pkg:npm/lodash@4.17.0",
TenantId = "tenant-002",
State = ObservationState.Investigating,
CreatedAt = now,
UpdatedAt = now
};
Assert.Equal("1.0", node.SchemaVersion);
}
[Fact]
public void CveObservationNode_OptionalSignals_AreNullByDefault()
{
var now = DateTimeOffset.UtcNow;
var node = new CveObservationNode
{
NodeId = "sha256:def",
CveId = "CVE-2024-0003",
Product = "pkg:pypi/requests@2.28.0",
TenantId = "tenant-003",
State = ObservationState.Affected,
CreatedAt = now,
UpdatedAt = now
};
Assert.Null(node.Epss);
Assert.Null(node.Kev);
Assert.Null(node.Vex);
Assert.Null(node.Reachability);
Assert.Null(node.MissingSignals);
}
[Fact]
public void CveObservationNode_WithSignals_ContainsSnapshots()
{
var now = DateTimeOffset.UtcNow;
var epssSignal = new SignalSnapshot
{
Status = "available",
ValueSummary = "0.15",
CapturedAt = now,
Source = "FIRST EPSS"
};
var node = new CveObservationNode
{
NodeId = "sha256:ghi",
CveId = "CVE-2024-0004",
Product = "pkg:golang/github.com/example/lib@v1.0.0",
TenantId = "tenant-004",
State = ObservationState.Fixed,
CreatedAt = now,
UpdatedAt = now,
Epss = epssSignal
};
Assert.NotNull(node.Epss);
Assert.Equal("available", node.Epss.Status);
Assert.Equal("0.15", node.Epss.ValueSummary);
}
[Fact]
public void CveObservationNode_UncertaintyScore_DefaultsToZero()
{
var now = DateTimeOffset.UtcNow;
var node = new CveObservationNode
{
NodeId = "sha256:jkl",
CveId = "CVE-2024-0005",
Product = "pkg:maven/com.example/lib@1.0.0",
TenantId = "tenant-005",
State = ObservationState.NotAffected,
CreatedAt = now,
UpdatedAt = now
};
Assert.Equal(0.0, node.UncertaintyScore);
}
[Fact]
public void CveObservationNode_WithMissingSignals_ContainsSignalNames()
{
var now = DateTimeOffset.UtcNow;
var node = new CveObservationNode
{
NodeId = "sha256:mno",
CveId = "CVE-2024-0006",
Product = "pkg:nuget/Newtonsoft.Json@13.0.1",
TenantId = "tenant-006",
State = ObservationState.Investigating,
CreatedAt = now,
UpdatedAt = now,
MissingSignals = ["epss", "kev"],
UncertaintyScore = 0.5
};
Assert.NotNull(node.MissingSignals);
Assert.Equal(2, node.MissingSignals.Count);
Assert.Contains("epss", node.MissingSignals);
Assert.Contains("kev", node.MissingSignals);
Assert.Equal(0.5, node.UncertaintyScore);
}
}
/// <summary>
/// Tests for SignalSnapshot record.
/// </summary>
public sealed class SignalSnapshotTests
{
[Fact]
public void SignalSnapshot_RequiredProperties_MustBeSet()
{
var capturedAt = DateTimeOffset.UtcNow;
var snapshot = new SignalSnapshot
{
Status = "available",
CapturedAt = capturedAt
};
Assert.Equal("available", snapshot.Status);
Assert.Equal(capturedAt, snapshot.CapturedAt);
}
[Fact]
public void SignalSnapshot_OptionalProperties_CanBeNull()
{
var snapshot = new SignalSnapshot
{
Status = "unavailable",
CapturedAt = DateTimeOffset.UtcNow
};
Assert.Null(snapshot.ValueSummary);
Assert.Null(snapshot.Source);
Assert.Null(snapshot.TtlRemaining);
}
[Fact]
public void SignalSnapshot_WithAllProperties_ContainsValues()
{
var capturedAt = DateTimeOffset.UtcNow;
var ttl = TimeSpan.FromHours(24);
var snapshot = new SignalSnapshot
{
Status = "available",
ValueSummary = "{\"score\": 0.85}",
CapturedAt = capturedAt,
Source = "NVD",
TtlRemaining = ttl
};
Assert.Equal("{\"score\": 0.85}", snapshot.ValueSummary);
Assert.Equal("NVD", snapshot.Source);
Assert.Equal(ttl, snapshot.TtlRemaining);
}
[Fact]
public void SignalSnapshot_RecordEquality_WorksCorrectly()
{
var capturedAt = DateTimeOffset.UtcNow;
var s1 = new SignalSnapshot
{
Status = "available",
CapturedAt = capturedAt,
Source = "KEV"
};
var s2 = new SignalSnapshot
{
Status = "available",
CapturedAt = capturedAt,
Source = "KEV"
};
Assert.Equal(s1, s2);
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Graph.Core\StellaOps.Graph.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1
}

View File

@@ -0,0 +1,18 @@
# Integrations Plugin Tests - AGENTS.md
## Module Overview
Test project for Integration connector plugins including InMemory and Harbor connectors.
## Test Coverage
- `InMemoryConnectorPluginTests` - Tests for the InMemory connector plugin (test/dev purposes)
- Additional tests for Harbor connector plugin to be added
## Working Agreements
1. Use deterministic TimeProvider injection for time-dependent tests
2. Mock HTTP connections for external service tests
3. Verify cancellation token propagation
## Running Tests
```bash
dotnet test src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/StellaOps.Integrations.Plugin.Tests.csproj
```

View File

@@ -0,0 +1,136 @@
using StellaOps.Integrations.Core;
using StellaOps.Integrations.Plugin.InMemory;
using Xunit;
namespace StellaOps.Integrations.Plugin.Tests;
/// <summary>
/// Tests for InMemoryConnectorPlugin.
/// </summary>
public sealed class InMemoryConnectorPluginTests
{
private static readonly DateTimeOffset FixedTime = new(2026, 1, 13, 10, 0, 0, TimeSpan.Zero);
[Fact]
public void Name_ReturnsInMemory()
{
var plugin = new InMemoryConnectorPlugin();
Assert.Equal("inmemory", plugin.Name);
}
[Fact]
public void Type_ReturnsRegistry()
{
var plugin = new InMemoryConnectorPlugin();
Assert.Equal(IntegrationType.Registry, plugin.Type);
}
[Fact]
public void Provider_ReturnsInMemory()
{
var plugin = new InMemoryConnectorPlugin();
Assert.Equal(IntegrationProvider.InMemory, plugin.Provider);
}
[Fact]
public void IsAvailable_ReturnsTrue()
{
var plugin = new InMemoryConnectorPlugin();
Assert.True(plugin.IsAvailable(null!));
}
[Fact]
public async Task TestConnectionAsync_ReturnsSuccess()
{
var fakeTime = new FixedTimeProvider(FixedTime);
var plugin = new InMemoryConnectorPlugin(fakeTime);
var config = CreateTestConfig();
var result = await plugin.TestConnectionAsync(config);
Assert.True(result.Success);
Assert.Contains("successful", result.Message, StringComparison.OrdinalIgnoreCase);
Assert.NotNull(result.Details);
Assert.Equal("true", result.Details["simulated"]);
}
[Fact]
public async Task TestConnectionAsync_IncludesEndpointInDetails()
{
var fakeTime = new FixedTimeProvider(FixedTime);
var plugin = new InMemoryConnectorPlugin(fakeTime);
var config = CreateTestConfig("https://test.example.com");
var result = await plugin.TestConnectionAsync(config);
Assert.NotNull(result.Details);
Assert.Equal("https://test.example.com", result.Details["endpoint"]);
}
[Fact]
public async Task CheckHealthAsync_ReturnsHealthy()
{
var fakeTime = new FixedTimeProvider(FixedTime);
var plugin = new InMemoryConnectorPlugin(fakeTime);
var config = CreateTestConfig();
var result = await plugin.CheckHealthAsync(config);
Assert.Equal(HealthStatus.Healthy, result.Status);
Assert.Contains("healthy", result.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task CheckHealthAsync_UsesInjectedTimeProvider()
{
var fakeTime = new FixedTimeProvider(FixedTime);
var plugin = new InMemoryConnectorPlugin(fakeTime);
var config = CreateTestConfig();
var result = await plugin.CheckHealthAsync(config);
// CheckedAt should be based on the fake time (plus ~50ms simulated delay)
Assert.True(result.CheckedAt >= FixedTime);
}
[Fact]
public async Task TestConnectionAsync_RespectsCanellation()
{
var plugin = new InMemoryConnectorPlugin();
var config = CreateTestConfig();
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAsync<TaskCanceledException>(
() => plugin.TestConnectionAsync(config, cts.Token));
}
private static IntegrationConfig CreateTestConfig(string endpoint = "https://example.com")
{
return new IntegrationConfig(
IntegrationId: Guid.NewGuid(),
Type: IntegrationType.Registry,
Provider: IntegrationProvider.InMemory,
Endpoint: endpoint,
ResolvedSecret: null,
OrganizationId: null,
ExtendedConfig: null);
}
/// <summary>
/// Simple fixed-time provider for deterministic tests.
/// </summary>
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.InMemory\StellaOps.Integrations.Plugin.InMemory.csproj" />
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.Harbor\StellaOps.Integrations.Plugin.Harbor.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Persistence\StellaOps.Integrations.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1
}

View File

@@ -0,0 +1,138 @@
using StellaOps.Notify.Connectors.Shared;
using Xunit;
namespace StellaOps.Notify.Connectors.Shared.Tests;
/// <summary>
/// Tests for ConnectorValueRedactor class.
/// </summary>
public sealed class ConnectorValueRedactorTests
{
[Fact]
public void RedactSecret_ReturnsConstantMask()
{
var result = ConnectorValueRedactor.RedactSecret("my-super-secret-value");
Assert.Equal("***", result);
}
[Fact]
public void RedactToken_ShortToken_ReturnsMask()
{
var result = ConnectorValueRedactor.RedactToken("short");
Assert.Equal("***", result);
}
[Fact]
public void RedactToken_LongToken_PreservePrefixAndSuffix()
{
var token = "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx1234";
var result = ConnectorValueRedactor.RedactToken(token);
Assert.StartsWith("ghp_xx", result);
Assert.EndsWith("1234", result);
Assert.Contains("***", result);
}
[Fact]
public void RedactToken_CustomLengths_Applied()
{
var token = "my-very-long-token-value-here";
var result = ConnectorValueRedactor.RedactToken(token, prefixLength: 3, suffixLength: 5);
Assert.StartsWith("my-", result);
Assert.EndsWith("-here", result);
}
[Fact]
public void RedactToken_NullValue_ReturnsMask()
{
var result = ConnectorValueRedactor.RedactToken(null!);
Assert.Equal("***", result);
}
[Fact]
public void RedactToken_EmptyValue_ReturnsMask()
{
var result = ConnectorValueRedactor.RedactToken("");
Assert.Equal("***", result);
}
[Fact]
public void RedactToken_WhitespaceValue_ReturnsMask()
{
var result = ConnectorValueRedactor.RedactToken(" ");
Assert.Equal("***", result);
}
[Theory]
[InlineData("token")]
[InlineData("auth_token")]
[InlineData("api_secret")]
[InlineData("Authorization")]
[InlineData("session_cookie")]
[InlineData("password")]
[InlineData("api_key")]
[InlineData("user_credential")]
public void IsSensitiveKey_SensitiveKeys_ReturnsTrue(string key)
{
var result = ConnectorValueRedactor.IsSensitiveKey(key);
Assert.True(result);
}
[Theory]
[InlineData("username")]
[InlineData("host")]
[InlineData("port")]
[InlineData("channel")]
[InlineData("recipient")]
public void IsSensitiveKey_NonSensitiveKeys_ReturnsFalse(string key)
{
var result = ConnectorValueRedactor.IsSensitiveKey(key);
Assert.False(result);
}
[Fact]
public void IsSensitiveKey_NullKey_ReturnsFalse()
{
var result = ConnectorValueRedactor.IsSensitiveKey(null!);
Assert.False(result);
}
[Fact]
public void IsSensitiveKey_EmptyKey_ReturnsFalse()
{
var result = ConnectorValueRedactor.IsSensitiveKey("");
Assert.False(result);
}
[Fact]
public void IsSensitiveKey_WhitespaceKey_ReturnsFalse()
{
var result = ConnectorValueRedactor.IsSensitiveKey(" ");
Assert.False(result);
}
[Fact]
public void IsSensitiveKey_CustomFragments_Used()
{
var customFragments = new[] { "custom", "special" };
Assert.True(ConnectorValueRedactor.IsSensitiveKey("my_custom_key", customFragments));
Assert.True(ConnectorValueRedactor.IsSensitiveKey("special_value", customFragments));
Assert.False(ConnectorValueRedactor.IsSensitiveKey("token", customFragments));
}
[Fact]
public void DefaultSensitiveKeyFragments_ContainsExpectedFragments()
{
var fragments = ConnectorValueRedactor.DefaultSensitiveKeyFragments;
Assert.Contains("token", fragments);
Assert.Contains("secret", fragments);
Assert.Contains("authorization", fragments);
Assert.Contains("cookie", fragments);
Assert.Contains("password", fragments);
Assert.Contains("key", fragments);
Assert.Contains("credential", fragments);
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Notify.Connectors.Shared\StellaOps.Notify.Connectors.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1
}

View File

@@ -0,0 +1,273 @@
using StellaOps.Notify.Storage.InMemory.Documents;
using StellaOps.Notify.Storage.InMemory.Repositories;
using Xunit;
namespace StellaOps.Notify.Storage.InMemory.Tests;
/// <summary>
/// Fake TimeProvider for deterministic testing.
/// </summary>
public sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow;
public FakeTimeProvider(DateTimeOffset? initialTime = null)
{
_utcNow = initialTime ?? new DateTimeOffset(2026, 1, 14, 12, 0, 0, TimeSpan.Zero);
}
public override DateTimeOffset GetUtcNow() => _utcNow;
public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration);
public void SetUtcNow(DateTimeOffset time) => _utcNow = time;
}
/// <summary>
/// Tests for NotifyChannelRepositoryAdapter.
/// </summary>
public sealed class NotifyChannelRepositoryAdapterTests
{
private readonly FakeTimeProvider _timeProvider = new();
[Fact]
public async Task UpsertAsync_NewChannel_SetsUpdatedAt()
{
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
var channel = new NotifyChannelDocument
{
Id = "ch-001",
TenantId = "tenant-001",
Name = "Email Channel",
ChannelType = "email",
Enabled = true
};
var result = await repo.UpsertAsync(channel);
Assert.Equal(_timeProvider.GetUtcNow(), result.UpdatedAt);
}
[Fact]
public async Task GetByIdAsync_ExistingChannel_ReturnsChannel()
{
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
var channel = new NotifyChannelDocument
{
Id = "ch-002",
TenantId = "tenant-001",
Name = "Slack Channel",
ChannelType = "slack"
};
await repo.UpsertAsync(channel);
var result = await repo.GetByIdAsync("tenant-001", "ch-002");
Assert.NotNull(result);
Assert.Equal("ch-002", result.Id);
Assert.Equal("Slack Channel", result.Name);
}
[Fact]
public async Task GetByIdAsync_NonExistent_ReturnsNull()
{
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
var result = await repo.GetByIdAsync("tenant-001", "non-existent");
Assert.Null(result);
}
[Fact]
public async Task GetByNameAsync_ExistingChannel_ReturnsChannel()
{
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
var channel = new NotifyChannelDocument
{
Id = "ch-003",
TenantId = "tenant-001",
Name = "Teams Notifications",
ChannelType = "teams"
};
await repo.UpsertAsync(channel);
var result = await repo.GetByNameAsync("tenant-001", "Teams Notifications");
Assert.NotNull(result);
Assert.Equal("ch-003", result.Id);
}
[Fact]
public async Task GetAllAsync_FilteredByEnabled_ReturnsOnlyEnabled()
{
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-e1", TenantId = "t1", Name = "E1", ChannelType = "email", Enabled = true });
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-e2", TenantId = "t1", Name = "E2", ChannelType = "email", Enabled = false });
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-e3", TenantId = "t1", Name = "E3", ChannelType = "slack", Enabled = true });
var enabled = await repo.GetAllAsync("t1", enabled: true);
var disabled = await repo.GetAllAsync("t1", enabled: false);
Assert.Equal(2, enabled.Count);
Assert.Single(disabled);
}
[Fact]
public async Task GetAllAsync_FilteredByChannelType_ReturnsMatchingType()
{
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-t1", TenantId = "t1", Name = "T1", ChannelType = "email" });
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-t2", TenantId = "t1", Name = "T2", ChannelType = "slack" });
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-t3", TenantId = "t1", Name = "T3", ChannelType = "email" });
var result = await repo.GetAllAsync("t1", channelType: "email");
Assert.Equal(2, result.Count);
Assert.All(result, c => Assert.Equal("email", c.ChannelType));
}
[Fact]
public async Task DeleteAsync_ExistingChannel_ReturnsTrue()
{
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-del", TenantId = "t1", Name = "Delete Me", ChannelType = "webhook" });
var deleted = await repo.DeleteAsync("t1", "ch-del");
var afterDelete = await repo.GetByIdAsync("t1", "ch-del");
Assert.True(deleted);
Assert.Null(afterDelete);
}
[Fact]
public async Task DeleteAsync_NonExistent_ReturnsFalse()
{
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
var deleted = await repo.DeleteAsync("t1", "non-existent");
Assert.False(deleted);
}
[Fact]
public async Task GetEnabledByTypeAsync_ReturnsOnlyEnabledOfType()
{
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-ebt1", TenantId = "t1", Name = "EBT1", ChannelType = "slack", Enabled = true });
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-ebt2", TenantId = "t1", Name = "EBT2", ChannelType = "slack", Enabled = false });
await repo.UpsertAsync(new NotifyChannelDocument { Id = "ch-ebt3", TenantId = "t1", Name = "EBT3", ChannelType = "email", Enabled = true });
var result = await repo.GetEnabledByTypeAsync("t1", "slack");
Assert.Single(result);
Assert.Equal("ch-ebt1", result[0].Id);
}
[Fact]
public async Task UpsertAsync_UpdateExisting_UpdatesTimestamp()
{
var repo = new NotifyChannelRepositoryAdapter(_timeProvider);
var channel = new NotifyChannelDocument { Id = "ch-upd", TenantId = "t1", Name = "Original", ChannelType = "email" };
await repo.UpsertAsync(channel);
var firstUpdate = _timeProvider.GetUtcNow();
_timeProvider.Advance(TimeSpan.FromMinutes(5));
channel.Name = "Updated";
await repo.UpsertAsync(channel);
var result = await repo.GetByIdAsync("t1", "ch-upd");
Assert.NotNull(result);
Assert.Equal("Updated", result.Name);
Assert.True(result.UpdatedAt > firstUpdate);
}
}
/// <summary>
/// Tests for NotifyChannelDocument.
/// </summary>
public sealed class NotifyChannelDocumentTests
{
[Fact]
public void NotifyChannelDocument_DefaultValues_AreSet()
{
var doc = new NotifyChannelDocument();
Assert.NotEmpty(doc.Id);
Assert.Equal(string.Empty, doc.TenantId);
Assert.Equal(string.Empty, doc.Name);
Assert.Equal(string.Empty, doc.ChannelType);
Assert.True(doc.Enabled);
Assert.Equal("{}", doc.Config);
Assert.Null(doc.Credentials);
Assert.Equal("{}", doc.Metadata);
}
}
/// <summary>
/// Tests for NotifyRuleDocument.
/// </summary>
public sealed class NotifyRuleDocumentTests
{
[Fact]
public void NotifyRuleDocument_DefaultValues_AreSet()
{
var doc = new NotifyRuleDocument();
Assert.NotEmpty(doc.Id);
Assert.Equal(string.Empty, doc.TenantId);
Assert.Equal(string.Empty, doc.Name);
Assert.True(doc.Enabled);
Assert.Equal(0, doc.Priority);
Assert.Equal("{}", doc.EventFilter);
}
}
/// <summary>
/// Tests for NotifyTemplateDocument.
/// </summary>
public sealed class NotifyTemplateDocumentTests
{
[Fact]
public void NotifyTemplateDocument_DefaultValues_AreSet()
{
var doc = new NotifyTemplateDocument();
Assert.NotEmpty(doc.Id);
Assert.Equal(string.Empty, doc.TenantId);
Assert.Equal(string.Empty, doc.Name);
Assert.Equal(string.Empty, doc.Subject);
Assert.Equal(string.Empty, doc.Body);
Assert.Equal("text", doc.Format);
}
}
/// <summary>
/// Tests for NotifyDeliveryDocument.
/// </summary>
public sealed class NotifyDeliveryDocumentTests
{
[Fact]
public void NotifyDeliveryDocument_DefaultValues_AreSet()
{
var doc = new NotifyDeliveryDocument();
Assert.NotEmpty(doc.Id);
Assert.Equal(string.Empty, doc.TenantId);
Assert.Equal("pending", doc.Status);
Assert.Equal(0, doc.RetryCount);
Assert.Equal("{}", doc.Payload);
}
[Theory]
[InlineData("pending")]
[InlineData("sending")]
[InlineData("sent")]
[InlineData("failed")]
[InlineData("retrying")]
public void NotifyDeliveryDocument_Status_SupportedValues(string status)
{
var doc = new NotifyDeliveryDocument { Status = status };
Assert.Equal(status, doc.Status);
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Notify.Storage.InMemory\StellaOps.Notify.Storage.InMemory.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1
}

View File

@@ -0,0 +1,17 @@
# Plugin SDK Tests - AGENTS.md
## Module Overview
Test project for the Plugin SDK which provides base classes and builders for plugin development.
## Test Coverage
- `PluginInfoBuilderTests` - Tests for the fluent PluginInfo builder (required fields, optional fields, validation)
## Working Agreements
1. Test both success and failure paths for builders
2. Verify validation exceptions contain meaningful messages
3. Test fluent builder chaining
## Running Tests
```bash
dotnet test src/Plugin/__Tests/StellaOps.Plugin.Sdk.Tests/StellaOps.Plugin.Sdk.Tests.csproj
```

View File

@@ -0,0 +1,115 @@
using StellaOps.Plugin.Sdk;
using Xunit;
namespace StellaOps.Plugin.Sdk.Tests;
/// <summary>
/// Tests for PluginInfoBuilder.
/// </summary>
public sealed class PluginInfoBuilderTests
{
[Fact]
public void Build_WithRequiredFields_CreatesPluginInfo()
{
var builder = new PluginInfoBuilder()
.WithId("com.stellaops.test.plugin")
.WithName("Test Plugin")
.WithVendor("StellaOps");
var info = builder.Build();
Assert.Equal("com.stellaops.test.plugin", info.Id);
Assert.Equal("Test Plugin", info.Name);
Assert.Equal("StellaOps", info.Vendor);
Assert.Equal("1.0.0", info.Version); // Default version
}
[Fact]
public void Build_WithAllFields_CreatesPluginInfo()
{
var builder = new PluginInfoBuilder()
.WithId("com.example.full.plugin")
.WithName("Full Plugin")
.WithVersion("2.1.0")
.WithVendor("Example Corp")
.WithDescription("A fully configured plugin")
.WithLicense("MIT")
.WithProjectUrl("https://example.com/plugin")
.WithIconUrl("https://example.com/icon.png");
var info = builder.Build();
Assert.Equal("com.example.full.plugin", info.Id);
Assert.Equal("Full Plugin", info.Name);
Assert.Equal("2.1.0", info.Version);
Assert.Equal("Example Corp", info.Vendor);
Assert.Equal("A fully configured plugin", info.Description);
Assert.Equal("MIT", info.LicenseId);
Assert.Equal("https://example.com/plugin", info.ProjectUrl);
Assert.Equal("https://example.com/icon.png", info.IconUrl);
}
[Fact]
public void Build_WithoutId_ThrowsInvalidOperationException()
{
var builder = new PluginInfoBuilder()
.WithName("Test Plugin");
var ex = Assert.Throws<InvalidOperationException>(() => builder.Build());
Assert.Contains("ID", ex.Message);
}
[Fact]
public void Build_WithoutName_ThrowsInvalidOperationException()
{
var builder = new PluginInfoBuilder()
.WithId("com.test.plugin");
var ex = Assert.Throws<InvalidOperationException>(() => builder.Build());
Assert.Contains("name", ex.Message);
}
[Fact]
public void Build_WithEmptyId_ThrowsInvalidOperationException()
{
var builder = new PluginInfoBuilder()
.WithId("")
.WithName("Test");
var ex = Assert.Throws<InvalidOperationException>(() => builder.Build());
Assert.Contains("ID", ex.Message);
}
[Fact]
public void Builder_IsChainable()
{
var info = new PluginInfoBuilder()
.WithId("com.test.chain")
.WithName("Chain Test")
.WithVersion("1.0.0")
.WithVendor("Test")
.WithDescription("desc")
.WithLicense("Apache-2.0")
.WithProjectUrl("https://test.com")
.WithIconUrl("https://test.com/icon")
.Build();
Assert.NotNull(info);
Assert.Equal("com.test.chain", info.Id);
}
[Fact]
public void Build_OptionalFields_CanBeOmitted()
{
var info = new PluginInfoBuilder()
.WithId("com.test.minimal")
.WithName("Minimal Plugin")
.WithVendor("") // Empty vendor is allowed
.Build();
Assert.Null(info.Description);
Assert.Null(info.LicenseId);
Assert.Null(info.ProjectUrl);
Assert.Null(info.IconUrl);
}
}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Plugin.Sdk\StellaOps.Plugin.Sdk.csproj" />
<ProjectReference Include="..\..\StellaOps.Plugin.Abstractions\StellaOps.Plugin.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1
}

View File

@@ -367,5 +367,5 @@ app.Run();
// Make Program class internal to prevent type conflicts when referencing this assembly
namespace StellaOps.Policy.Engine
{
internal partial class Program { }
public partial class Program { }
}

View File

@@ -0,0 +1,277 @@
using StellaOps.Policy.AuthSignals;
using Xunit;
namespace StellaOps.Policy.AuthSignals.Tests;
/// <summary>
/// Tests for PolicyAuthSignal and related models.
/// </summary>
public sealed class PolicyAuthSignalTests
{
[Fact]
public void PolicyAuthSignal_RequiredProperties_MustBeSet()
{
var signal = new PolicyAuthSignal
{
Id = "sig-001",
Tenant = "tenant-abc",
Subject = "artifact:sha256:abc123",
SignalType = "reachability",
Source = "scanner-v1",
Created = DateTime.UtcNow
};
Assert.Equal("sig-001", signal.Id);
Assert.Equal("tenant-abc", signal.Tenant);
Assert.Equal("artifact:sha256:abc123", signal.Subject);
Assert.Equal("reachability", signal.SignalType);
Assert.Equal("scanner-v1", signal.Source);
}
[Theory]
[InlineData("reachability")]
[InlineData("attestation")]
[InlineData("risk")]
[InlineData("vex")]
public void PolicyAuthSignal_SignalType_SupportedValues(string signalType)
{
var signal = new PolicyAuthSignal
{
Id = "sig-type-test",
Tenant = "t1",
Subject = "s1",
SignalType = signalType,
Source = "test",
Created = DateTime.UtcNow
};
Assert.Equal(signalType, signal.SignalType);
}
[Fact]
public void PolicyAuthSignal_WithConfidence_ContainsValue()
{
var signal = new PolicyAuthSignal
{
Id = "sig-conf",
Tenant = "t1",
Subject = "s1",
SignalType = "risk",
Source = "risk-engine",
Confidence = 0.95,
Created = DateTime.UtcNow
};
Assert.Equal(0.95, signal.Confidence);
}
[Fact]
public void PolicyAuthSignal_WithEvidence_ContainsRefs()
{
var evidence = new[]
{
new EvidenceRef
{
Kind = "attestation",
Uri = "oci://registry.io/attestation@sha256:xyz",
Digest = "sha256:xyz123"
},
new EvidenceRef
{
Kind = "linkset",
Uri = "https://transparency.example.com/entry/123",
Digest = "sha256:link456",
Scope = "org.example.project"
}
};
var signal = new PolicyAuthSignal
{
Id = "sig-ev",
Tenant = "t1",
Subject = "s1",
SignalType = "attestation",
Source = "attestor",
Evidence = evidence,
Created = DateTime.UtcNow
};
Assert.Equal(2, signal.Evidence.Count);
Assert.Equal("attestation", signal.Evidence[0].Kind);
Assert.Equal("linkset", signal.Evidence[1].Kind);
Assert.Equal("org.example.project", signal.Evidence[1].Scope);
}
[Fact]
public void PolicyAuthSignal_WithProvenance_ContainsDetails()
{
var provenance = new Provenance
{
Pipeline = "ci/build-and-scan",
Inputs = new[] { "dockerfile:sha256:abc", "sources:sha256:def" },
Signer = "build-bot@ci.example.com",
Transparency = new Transparency
{
RekorUuid = "rekor-uuid-123456"
}
};
var signal = new PolicyAuthSignal
{
Id = "sig-prov",
Tenant = "t1",
Subject = "s1",
SignalType = "attestation",
Source = "signer",
Provenance = provenance,
Created = DateTime.UtcNow
};
Assert.NotNull(signal.Provenance);
Assert.Equal("ci/build-and-scan", signal.Provenance.Pipeline);
Assert.Equal(2, signal.Provenance.Inputs!.Count);
Assert.Equal("build-bot@ci.example.com", signal.Provenance.Signer);
Assert.Equal("rekor-uuid-123456", signal.Provenance.Transparency!.RekorUuid);
}
[Fact]
public void PolicyAuthSignal_DefaultEvidence_IsEmpty()
{
var signal = new PolicyAuthSignal
{
Id = "sig-default",
Tenant = "t1",
Subject = "s1",
SignalType = "vex",
Source = "scanner",
Created = DateTime.UtcNow
};
Assert.Empty(signal.Evidence);
}
[Fact]
public void Transparency_WithSkipReason_NoRekorUuid()
{
var transparency = new Transparency
{
SkipReason = "airgapped_environment"
};
Assert.Null(transparency.RekorUuid);
Assert.Equal("airgapped_environment", transparency.SkipReason);
}
[Fact]
public void PolicyAuthSignal_RecordEquality_WorksCorrectly()
{
var created = DateTime.UtcNow;
var signal1 = new PolicyAuthSignal
{
Id = "sig-eq",
Tenant = "t1",
Subject = "s1",
SignalType = "risk",
Source = "engine",
Confidence = 0.8,
Created = created
};
var signal2 = new PolicyAuthSignal
{
Id = "sig-eq",
Tenant = "t1",
Subject = "s1",
SignalType = "risk",
Source = "engine",
Confidence = 0.8,
Created = created
};
Assert.Equal(signal1, signal2);
Assert.Equal(signal1.GetHashCode(), signal2.GetHashCode());
}
}
/// <summary>
/// Tests for EvidenceRef record.
/// </summary>
public sealed class EvidenceRefTests
{
[Theory]
[InlineData("linkset")]
[InlineData("runtime")]
[InlineData("attestation")]
[InlineData("bundle")]
public void EvidenceRef_Kind_SupportedValues(string kind)
{
var evidenceRef = new EvidenceRef
{
Kind = kind,
Uri = "https://example.com/evidence",
Digest = "sha256:abc123"
};
Assert.Equal(kind, evidenceRef.Kind);
}
[Fact]
public void EvidenceRef_DefaultValues_AreEmpty()
{
var evidenceRef = new EvidenceRef();
Assert.Equal(string.Empty, evidenceRef.Kind);
Assert.Equal(string.Empty, evidenceRef.Uri);
Assert.Equal(string.Empty, evidenceRef.Digest);
Assert.Null(evidenceRef.Scope);
}
[Fact]
public void EvidenceRef_WithScope_ContainsValue()
{
var evidenceRef = new EvidenceRef
{
Kind = "runtime",
Uri = "https://example.com/runtime-check",
Digest = "sha256:runtime123",
Scope = "org.example.service.api"
};
Assert.Equal("org.example.service.api", evidenceRef.Scope);
}
}
/// <summary>
/// Tests for Provenance record.
/// </summary>
public sealed class ProvenanceTests
{
[Fact]
public void Provenance_AllPropertiesOptional()
{
var provenance = new Provenance();
Assert.Null(provenance.Pipeline);
Assert.Null(provenance.Inputs);
Assert.Null(provenance.Signer);
Assert.Null(provenance.Transparency);
}
[Fact]
public void Provenance_WithAllProperties_ContainsValues()
{
var provenance = new Provenance
{
Pipeline = "github-actions/build",
Inputs = new[] { "src:sha256:123", "config:sha256:456" },
Signer = "sigstore-bot",
Transparency = new Transparency { RekorUuid = "uuid-789" }
};
Assert.Equal("github-actions/build", provenance.Pipeline);
Assert.Equal(2, provenance.Inputs!.Count);
Assert.Equal("sigstore-bot", provenance.Signer);
Assert.NotNull(provenance.Transparency);
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<UseXunitV3>true</UseXunitV3>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.AuthSignals\StellaOps.Policy.AuthSignals.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"diagnosticMessages": true,
"parallelizeAssembly": true,
"parallelizeTestCollections": true,
"maxParallelThreads": -1
}

View File

@@ -127,7 +127,7 @@ internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSche
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock)
TimeProvider clock)
: base(options, logger, encoder, clock)
{
}
@@ -154,3 +154,4 @@ internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSche
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -0,0 +1,308 @@
using StellaOps.Policy.Predicates.FixChain;
using System.Collections.Immutable;
using Xunit;
namespace StellaOps.Policy.Predicates.Tests;
/// <summary>
/// Tests for FixChainGateAction enum.
/// </summary>
public sealed class FixChainGateActionTests
{
[Theory]
[InlineData(FixChainGateAction.Block)]
[InlineData(FixChainGateAction.Warn)]
public void FixChainGateAction_AllValues_AreDefined(FixChainGateAction action)
{
Assert.True(Enum.IsDefined(action));
}
[Fact]
public void FixChainGateAction_AllValues_AreCounted()
{
var values = Enum.GetValues<FixChainGateAction>();
Assert.Equal(2, values.Length);
}
}
/// <summary>
/// Tests for FixChainGateOutcome enum.
/// </summary>
public sealed class FixChainGateOutcomeTests
{
[Theory]
[InlineData(FixChainGateOutcome.FixVerified)]
[InlineData(FixChainGateOutcome.SeverityExempt)]
[InlineData(FixChainGateOutcome.GracePeriod)]
[InlineData(FixChainGateOutcome.AttestationRequired)]
[InlineData(FixChainGateOutcome.InsufficientConfidence)]
[InlineData(FixChainGateOutcome.InconclusiveNotAllowed)]
[InlineData(FixChainGateOutcome.StillVulnerable)]
[InlineData(FixChainGateOutcome.GoldenSetNotApproved)]
[InlineData(FixChainGateOutcome.PartialFix)]
public void FixChainGateOutcome_AllValues_AreDefined(FixChainGateOutcome outcome)
{
Assert.True(Enum.IsDefined(outcome));
}
[Fact]
public void FixChainGateOutcome_AllValues_AreCounted()
{
var values = Enum.GetValues<FixChainGateOutcome>();
Assert.Equal(9, values.Length);
}
}
/// <summary>
/// Tests for FixChainGateContext record.
/// </summary>
public sealed class FixChainGateContextTests
{
[Fact]
public void FixChainGateContext_RequiredProperties_MustBeSet()
{
var context = new FixChainGateContext
{
CveId = "CVE-2024-0001",
ComponentPurl = "pkg:deb/debian/nginx@1.18.0-6.1",
Severity = "critical",
CvssScore = 9.8m
};
Assert.Equal("CVE-2024-0001", context.CveId);
Assert.Equal("pkg:deb/debian/nginx@1.18.0-6.1", context.ComponentPurl);
Assert.Equal("critical", context.Severity);
Assert.Equal(9.8m, context.CvssScore);
}
[Fact]
public void FixChainGateContext_Environment_DefaultsToProduction()
{
var context = new FixChainGateContext
{
CveId = "CVE-2024-0001",
ComponentPurl = "pkg:npm/%40example/lib@1.0.0",
Severity = "high",
CvssScore = 7.5m
};
Assert.Equal("production", context.Environment);
}
[Fact]
public void FixChainGateContext_OptionalProperties_CanBeNull()
{
var context = new FixChainGateContext
{
CveId = "CVE-2024-0001",
ComponentPurl = "pkg:pypi/requests@2.28.0",
Severity = "medium",
CvssScore = 5.3m
};
Assert.Null(context.BinarySha256);
Assert.Null(context.CvePublishedAt);
Assert.Null(context.Metadata);
}
[Fact]
public void FixChainGateContext_WithAllOptionalProperties_ContainsValues()
{
var publishedAt = DateTimeOffset.UtcNow.AddDays(-10);
var metadata = new Dictionary<string, string>
{
["ticket"] = "SEC-123",
["reviewer"] = "security-team"
};
var context = new FixChainGateContext
{
CveId = "CVE-2024-0001",
ComponentPurl = "pkg:golang/github.com/example/lib@v1.2.3",
Severity = "high",
CvssScore = 8.1m,
BinarySha256 = "sha256:abc123def456",
CvePublishedAt = publishedAt,
Environment = "staging",
Metadata = metadata
};
Assert.Equal("sha256:abc123def456", context.BinarySha256);
Assert.Equal(publishedAt, context.CvePublishedAt);
Assert.Equal("staging", context.Environment);
Assert.NotNull(context.Metadata);
Assert.Equal("SEC-123", context.Metadata["ticket"]);
}
}
/// <summary>
/// Tests for FixChainGateParameters record.
/// </summary>
public sealed class FixChainGateParametersTests
{
[Fact]
public void FixChainGateParameters_HasSensibleDefaults()
{
var parameters = new FixChainGateParameters();
Assert.Equal(2, parameters.Severities.Length);
Assert.Contains("critical", parameters.Severities);
Assert.Contains("high", parameters.Severities);
Assert.Equal(0.85m, parameters.MinConfidence);
Assert.Equal(7, parameters.GracePeriodDays);
Assert.True(parameters.RequireApprovedGoldenSet);
Assert.Equal(FixChainGateAction.Block, parameters.FailureAction);
}
[Fact]
public void FixChainGateParameters_AllowInconclusive_DefaultsFalse()
{
var parameters = new FixChainGateParameters();
Assert.False(parameters.AllowInconclusive);
}
[Fact]
public void FixChainGateParameters_CustomConfiguration_OverridesDefaults()
{
var parameters = new FixChainGateParameters
{
Severities = ["critical"],
MinConfidence = 0.95m,
GracePeriodDays = 14,
AllowInconclusive = true,
RequireApprovedGoldenSet = false,
FailureAction = FixChainGateAction.Warn
};
Assert.Single(parameters.Severities);
Assert.Equal(0.95m, parameters.MinConfidence);
Assert.Equal(14, parameters.GracePeriodDays);
Assert.True(parameters.AllowInconclusive);
Assert.False(parameters.RequireApprovedGoldenSet);
Assert.Equal(FixChainGateAction.Warn, parameters.FailureAction);
}
}
/// <summary>
/// Tests for FixChainGateResult record.
/// </summary>
public sealed class FixChainGateResultTests
{
[Fact]
public void FixChainGateResult_PassingResult_HasCorrectProperties()
{
var result = new FixChainGateResult
{
Passed = true,
Outcome = FixChainGateOutcome.FixVerified,
Reason = "Fix verified with 0.92 confidence",
Action = FixChainGateAction.Block,
EvaluatedAt = DateTimeOffset.UtcNow
};
Assert.True(result.Passed);
Assert.Equal(FixChainGateOutcome.FixVerified, result.Outcome);
}
[Fact]
public void FixChainGateResult_FailingResult_HasRecommendations()
{
var result = new FixChainGateResult
{
Passed = false,
Outcome = FixChainGateOutcome.AttestationRequired,
Reason = "No fix attestation found for critical CVE",
Action = FixChainGateAction.Block,
EvaluatedAt = DateTimeOffset.UtcNow,
Recommendations = ["Run stella-cli verify-fix CVE-2024-0001", "Update component to patched version"]
};
Assert.False(result.Passed);
Assert.Equal(2, result.Recommendations.Length);
}
[Fact]
public void FixChainGateResult_WithAttestation_ContainsAttestationInfo()
{
var attestation = new FixChainAttestationInfo
{
ContentDigest = "sha256:abc123",
VerdictStatus = "fixed",
Confidence = 0.95m,
GoldenSetId = "golden-001",
VerifiedAt = DateTimeOffset.UtcNow.AddHours(-1)
};
var result = new FixChainGateResult
{
Passed = true,
Outcome = FixChainGateOutcome.FixVerified,
Reason = "Fix verified",
Action = FixChainGateAction.Block,
EvaluatedAt = DateTimeOffset.UtcNow,
Attestation = attestation
};
Assert.NotNull(result.Attestation);
Assert.Equal("sha256:abc123", result.Attestation.ContentDigest);
Assert.Equal(0.95m, result.Attestation.Confidence);
}
}
/// <summary>
/// Tests for FixChainAttestationInfo record.
/// </summary>
public sealed class FixChainAttestationInfoTests
{
[Fact]
public void FixChainAttestationInfo_RequiredProperties_MustBeSet()
{
var verifiedAt = DateTimeOffset.UtcNow;
var info = new FixChainAttestationInfo
{
ContentDigest = "sha256:xyz789",
VerdictStatus = "fixed",
Confidence = 0.88m,
VerifiedAt = verifiedAt
};
Assert.Equal("sha256:xyz789", info.ContentDigest);
Assert.Equal("fixed", info.VerdictStatus);
Assert.Equal(0.88m, info.Confidence);
Assert.Equal(verifiedAt, info.VerifiedAt);
}
[Fact]
public void FixChainAttestationInfo_WithRationale_ContainsItems()
{
var info = new FixChainAttestationInfo
{
ContentDigest = "sha256:abc",
VerdictStatus = "fixed",
Confidence = 0.90m,
VerifiedAt = DateTimeOffset.UtcNow,
Rationale = ["Patch applied in version 1.2.3", "Binary hash matches golden set"]
};
Assert.Equal(2, info.Rationale.Length);
}
}
/// <summary>
/// Tests for FixChainGateOptions record.
/// </summary>
public sealed class FixChainGateOptionsTests
{
[Fact]
public void FixChainGateOptions_HasSensibleDefaults()
{
var options = new FixChainGateOptions();
Assert.True(options.Enabled);
Assert.Equal(0.85m, options.DefaultMinConfidence);
Assert.Equal(7, options.DefaultGracePeriodDays);
Assert.True(options.NotifyOnBlock);
}
}

Some files were not shown because too many files have changed in this diff Show More