Add integration tests for migration categories and execution
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled

- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations.
- Added tests for edge cases, including null, empty, and whitespace migration names.
- Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers.
- Included tests for migration execution, schema creation, and handling of pending release migrations.
- Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
This commit is contained in:
master
2025-12-04 19:10:54 +02:00
parent 600f3a7a3c
commit 75f6942769
301 changed files with 32810 additions and 1128 deletions

View File

@@ -28,6 +28,7 @@ namespace StellaOps.Cli.Services;
internal sealed class BackendOperationsClient : IBackendOperationsClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private static readonly JsonSerializerOptions JsonOptions = SerializerOptions;
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
private static readonly IReadOnlyDictionary<string, object?> EmptyMetadata =
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(0, StringComparer.OrdinalIgnoreCase));
@@ -523,8 +524,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
@@ -639,8 +639,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
@@ -758,8 +757,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
@@ -807,8 +805,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
@@ -858,8 +855,7 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
var (message, errorCode) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
@@ -909,11 +905,11 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
throw new InvalidOperationException(failure);
}
var result = await response.Content.ReadFromJsonAsync<EntryTraceResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (result is null)
{
throw new InvalidOperationException("EntryTrace response payload was empty.");
}
var result = await response.Content.ReadFromJsonAsync<EntryTraceResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (result is null)
{
throw new InvalidOperationException("EntryTrace response payload was empty.");
}
return result;
}
@@ -4512,11 +4508,11 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
}
// CLI-SDK-64-001: SDK update operations
public async Task<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
public async Task<SdkUpdateResponse> CheckSdkUpdatesAsync(SdkUpdateRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
EnsureBackendConfigured();
OfflineModeGuard.ThrowIfOffline("sdk update");
var queryParams = new List<string>();
@@ -4554,9 +4550,9 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
};
}
var result = await response.Content.ReadFromJsonAsync<SdkUpdateResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? new SdkUpdateResponse { Success = false, Error = "Empty response" };
}
var result = await response.Content.ReadFromJsonAsync<SdkUpdateResponse>(JsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? new SdkUpdateResponse { Success = false, Error = "Empty response" };
}
public async Task<SdkListResponse> ListInstalledSdksAsync(string? language, string? tenant, CancellationToken cancellationToken)
{

View File

@@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -576,12 +577,10 @@ internal sealed class ExceptionClient : IExceptionClient
}
}
var result = await tokenClient.GetTokenAsync(
new StellaOpsTokenRequest { Scopes = [scope] },
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
try
{
var result = await tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = result.AccessToken;
@@ -589,8 +588,10 @@ internal sealed class ExceptionClient : IExceptionClient
}
return result.AccessToken;
}
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
return null;
catch (Exception ex)
{
logger.LogWarning(ex, "Token acquisition failed");
return null;
}
}
}

View File

@@ -0,0 +1,60 @@
using System.Reflection;
namespace StellaOps.Cli.Services;
/// <summary>
/// Defines a PostgreSQL module with its migration metadata.
/// </summary>
public sealed record MigrationModuleInfo(
string Name,
string SchemaName,
Assembly MigrationsAssembly,
string? ResourcePrefix = null);
/// <summary>
/// Registry of all PostgreSQL modules and their migration assemblies.
/// Stub implementation - actual module assemblies will be wired in Wave 3-8.
/// </summary>
public static class MigrationModuleRegistry
{
// TODO: Wire actual module assemblies when Storage.Postgres projects are implemented
// Modules will be registered as:
// - Authority (auth schema) - StellaOps.Authority.Storage.Postgres.AuthorityDataSource
// - Scheduler (scheduler schema) - StellaOps.Scheduler.Storage.Postgres.SchedulerDataSource
// - Concelier (vuln schema) - StellaOps.Concelier.Storage.Postgres.ConcelierDataSource
// - Policy (policy schema) - StellaOps.Policy.Storage.Postgres.PolicyDataSource
// - Notify (notify schema) - StellaOps.Notify.Storage.Postgres.NotifyDataSource
// - Excititor (vex schema) - StellaOps.Excititor.Storage.Postgres.ExcititorDataSource
private static readonly List<MigrationModuleInfo> _modules = [];
/// <summary>
/// Gets all registered modules.
/// </summary>
public static IReadOnlyList<MigrationModuleInfo> Modules => _modules;
/// <summary>
/// Gets module names for CLI completion.
/// </summary>
public static IEnumerable<string> ModuleNames => _modules.Select(m => m.Name);
/// <summary>
/// Finds a module by name (case-insensitive).
/// </summary>
public static MigrationModuleInfo? FindModule(string name) =>
_modules.FirstOrDefault(m =>
string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Gets modules matching the filter, or all if filter is null/empty.
/// </summary>
public static IEnumerable<MigrationModuleInfo> GetModules(string? moduleFilter)
{
if (string.IsNullOrWhiteSpace(moduleFilter) || moduleFilter.Equals("all", StringComparison.OrdinalIgnoreCase))
{
return _modules;
}
var module = FindModule(moduleFilter);
return module != null ? [module] : [];
}
}

View File

@@ -111,7 +111,7 @@ internal sealed class AttestationSubjectInfo
/// <summary>
/// Signature information for display.
/// </summary>
internal sealed class AttestationSignatureInfo
internal sealed record AttestationSignatureInfo
{
[JsonPropertyName("keyId")]
public string KeyId { get; init; } = string.Empty;
@@ -162,7 +162,7 @@ internal sealed class AttestationSignerInfo
/// <summary>
/// Summary of the predicate for display.
/// </summary>
internal sealed class AttestationPredicateSummary
internal sealed record AttestationPredicateSummary
{
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;

View File

@@ -519,17 +519,8 @@ internal sealed class PolicyDiagnostic
[JsonPropertyName("severity")]
public string Severity { get; init; } = "error";
[JsonPropertyName("line")]
public int? Line { get; init; }
[JsonPropertyName("column")]
public int? Column { get; init; }
[JsonPropertyName("span")]
public string? Span { get; init; }
[JsonPropertyName("suggestion")]
public string? Suggestion { get; init; }
[JsonPropertyName("path")]
public string? Path { get; init; }
}
// CLI-POLICY-27-002: Policy submission/review workflow models

View File

@@ -236,7 +236,7 @@ internal sealed class ReachabilityFunction
/// <summary>
/// Reachability override for policy simulation.
/// </summary>
internal sealed class ReachabilityOverride
internal sealed record ReachabilityOverride
{
[JsonPropertyName("vulnerabilityId")]
public string? VulnerabilityId { get; init; }

View File

@@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -634,12 +635,10 @@ internal sealed class NotifyClient : INotifyClient
}
}
var result = await tokenClient.GetTokenAsync(
new StellaOpsTokenRequest { Scopes = [scope] },
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
try
{
var result = await tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = result.AccessToken;
@@ -647,8 +646,10 @@ internal sealed class NotifyClient : INotifyClient
}
return result.AccessToken;
}
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
return null;
catch (Exception ex)
{
logger.LogWarning(ex, "Token acquisition failed");
return null;
}
}
}

View File

@@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -170,12 +171,10 @@ internal sealed class ObservabilityClient : IObservabilityClient
}
}
var result = await tokenClient.GetTokenAsync(
new StellaOpsTokenRequest { Scopes = ["obs:read"] },
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
try
{
var result = await tokenClient.GetAccessTokenAsync(StellaOpsScopes.ObservabilityRead, cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = result.AccessToken;
@@ -183,9 +182,11 @@ internal sealed class ObservabilityClient : IObservabilityClient
}
return result.AccessToken;
}
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
return null;
catch (Exception ex)
{
logger.LogWarning(ex, "Token acquisition failed");
return null;
}
}
// CLI-OBS-52-001: Trace retrieval

View File

@@ -0,0 +1,41 @@
using System;
namespace StellaOps.Cli.Services;
/// <summary>
/// Guard for operations that require network connectivity.
/// Stub implementation - will be wired to actual offline mode detection.
/// </summary>
internal static class OfflineModeGuard
{
/// <summary>
/// Gets whether the CLI is currently in offline mode.
/// </summary>
public static bool IsOffline { get; set; }
/// <summary>
/// Gets whether network operations are allowed.
/// </summary>
public static bool IsNetworkAllowed() => !IsOffline;
/// <summary>
/// Gets whether network operations are allowed, checking options and logging operation.
/// </summary>
/// <param name="options">CLI options (used to check offline mode setting).</param>
/// <param name="operationName">Name of the operation being checked.</param>
public static bool IsNetworkAllowed(object? options, string operationName) => !IsOffline;
/// <summary>
/// Throws if the CLI is in offline mode and the operation requires network.
/// </summary>
/// <param name="operationName">Name of the operation being guarded.</param>
/// <exception cref="InvalidOperationException">Thrown when offline and network required.</exception>
public static void ThrowIfOffline(string operationName)
{
if (IsOffline)
{
throw new InvalidOperationException(
$"Operation '{operationName}' requires network connectivity but CLI is in offline mode.");
}
}
}

View File

@@ -8,9 +8,10 @@ using System.Threading.Tasks;
using System.Web;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.Client.Scopes;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -386,7 +387,7 @@ internal sealed class OrchestratorClient : IOrchestratorClient
private async Task ConfigureAuthAsync(CancellationToken cancellationToken)
{
var token = await _tokenClient.GetCachedAccessTokenAsync(
new[] { StellaOpsScope.OrchRead },
new[] { StellaOpsScopes.OrchRead },
cancellationToken);
_httpClient.DefaultRequestHeaders.Authorization =

View File

@@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -997,12 +998,10 @@ internal sealed class PackClient : IPackClient
}
}
var result = await tokenClient.GetTokenAsync(
new StellaOpsTokenRequest { Scopes = [scope] },
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
try
{
var result = await tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = result.AccessToken;
@@ -1010,8 +1009,10 @@ internal sealed class PackClient : IPackClient
}
return result.AccessToken;
}
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
return null;
catch (Exception ex)
{
logger.LogWarning(ex, "Token acquisition failed");
return null;
}
}
}

View File

@@ -5,6 +5,7 @@ using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;

View File

@@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -463,12 +464,10 @@ internal sealed class SbomClient : ISbomClient
}
}
var result = await tokenClient.GetTokenAsync(
new StellaOpsTokenRequest { Scopes = [scope] },
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
try
{
var result = await tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = result.AccessToken;
@@ -476,8 +475,10 @@ internal sealed class SbomClient : ISbomClient
}
return result.AccessToken;
}
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
return null;
catch (Exception ex)
{
logger.LogWarning(ex, "Token acquisition failed");
return null;
}
}
}

View File

@@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Client;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -194,11 +195,18 @@ internal sealed class SbomerClient : ISbomerClient
if (_tokenClient == null)
return;
var token = await _tokenClient.GetTokenAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(token))
try
{
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var tokenResult = await _tokenClient.GetTokenAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(tokenResult.AccessToken))
{
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResult.AccessToken);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to acquire token for Sbomer API access.");
}
}

View File

@@ -9,6 +9,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
@@ -20,7 +21,7 @@ namespace StellaOps.Cli.Services;
internal sealed class VexObservationsClient : IVexObservationsClient
{
private readonly HttpClient _httpClient;
private readonly ITokenClient? _tokenClient;
private readonly IStellaOpsTokenClient? _tokenClient;
private readonly ILogger<VexObservationsClient> _logger;
private string? _cachedToken;
private DateTimeOffset _tokenExpiry;
@@ -33,7 +34,7 @@ internal sealed class VexObservationsClient : IVexObservationsClient
public VexObservationsClient(
HttpClient httpClient,
ILogger<VexObservationsClient> logger,
ITokenClient? tokenClient = null)
IStellaOpsTokenClient? tokenClient = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -138,20 +139,23 @@ internal sealed class VexObservationsClient : IVexObservationsClient
return;
}
var tokenResult = await _tokenClient.GetAccessTokenAsync(
new[] { StellaOpsScopes.VexRead },
cancellationToken).ConfigureAwait(false);
try
{
var tokenResult = await _tokenClient.GetAccessTokenAsync(
StellaOpsScopes.VexRead,
cancellationToken).ConfigureAwait(false);
if (tokenResult.IsSuccess && !string.IsNullOrWhiteSpace(tokenResult.AccessToken))
{
_cachedToken = tokenResult.AccessToken;
_tokenExpiry = DateTimeOffset.UtcNow.AddMinutes(55);
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _cachedToken);
if (!string.IsNullOrWhiteSpace(tokenResult.AccessToken))
{
_cachedToken = tokenResult.AccessToken;
_tokenExpiry = DateTimeOffset.UtcNow.AddMinutes(55);
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _cachedToken);
}
}
else
catch (Exception ex)
{
_logger.LogWarning("Failed to acquire token for VEX API access.");
_logger.LogWarning(ex, "Failed to acquire token for VEX API access.");
}
}