Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-04 21:36:12 +02:00
301 changed files with 32810 additions and 1128 deletions

View File

@@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Plugins;
using StellaOps.Cli.Services.Models.AdvisoryAi;
@@ -5182,13 +5183,13 @@ internal static class CommandFactory
Description = "Image digests to test (can be specified multiple times).",
AllowMultipleArgumentsPerToken = true
};
imagesOption.IsRequired = true;
imagesOption.Required = true;
var scannerOption = new Option<string>("--scanner", "-s")
{
Description = "Scanner container image reference."
};
scannerOption.IsRequired = true;
scannerOption.Required = true;
var policyBundleOption = new Option<string?>("--policy-bundle")
{
@@ -5582,13 +5583,13 @@ internal static class CommandFactory
{
Description = "Start timestamp (ISO-8601). Required."
};
fromOption.IsRequired = true;
fromOption.Required = true;
var toOption = new Option<DateTimeOffset>("--to")
{
Description = "End timestamp (ISO-8601). Required."
};
toOption.IsRequired = true;
toOption.Required = true;
var logsTenantOption = new Option<string?>("--tenant", "-t")
{
@@ -6544,7 +6545,7 @@ internal static class CommandFactory
var secretsInjectRefOption = new Option<string>("--secret-ref")
{
Description = "Secret reference (provider-specific path).",
IsRequired = true
Required = true
};
var secretsInjectProviderOption = new Option<string>("--provider")
@@ -6844,19 +6845,18 @@ internal static class CommandFactory
return CommandHandlers.HandleExceptionsListAsync(
services,
tenant,
vuln,
scopeType,
scopeValue,
statuses,
owner,
effect,
expiringDays,
expiringDays.HasValue ? DateTimeOffset.UtcNow.AddDays(expiringDays.Value) : null,
includeExpired,
pageSize,
pageToken,
tenant,
json,
csv,
json || csv,
verbose,
cancellationToken);
});
@@ -6977,7 +6977,8 @@ internal static class CommandFactory
var effect = parseResult.GetValue(createEffectOption) ?? string.Empty;
var justification = parseResult.GetValue(createJustificationOption) ?? string.Empty;
var owner = parseResult.GetValue(createOwnerOption) ?? string.Empty;
var expiration = parseResult.GetValue(createExpirationOption);
var expirationStr = parseResult.GetValue(createExpirationOption);
var expiration = !string.IsNullOrWhiteSpace(expirationStr) && DateTimeOffset.TryParse(expirationStr, out var exp) ? exp : (DateTimeOffset?)null;
var evidence = parseResult.GetValue(createEvidenceOption) ?? Array.Empty<string>();
var policy = parseResult.GetValue(createPolicyOption);
var stage = parseResult.GetValue(createStageOption);
@@ -6987,17 +6988,17 @@ internal static class CommandFactory
return CommandHandlers.HandleExceptionsCreateAsync(
services,
tenant ?? string.Empty,
vuln,
scopeType,
scopeValue,
effect,
justification,
owner,
owner ?? string.Empty,
expiration,
evidence,
policy,
stage,
tenant,
json,
verbose,
cancellationToken);
@@ -7042,9 +7043,9 @@ internal static class CommandFactory
return CommandHandlers.HandleExceptionsPromoteAsync(
services,
exceptionId,
target,
comment,
tenant,
target ?? "active",
comment,
json,
verbose,
cancellationToken);
@@ -7128,10 +7129,10 @@ internal static class CommandFactory
return CommandHandlers.HandleExceptionsImportAsync(
services,
tenant ?? string.Empty,
file,
stage,
source,
tenant,
json,
verbose,
cancellationToken);
@@ -7184,11 +7185,13 @@ internal static class CommandFactory
return CommandHandlers.HandleExceptionsExportAsync(
services,
output,
tenant,
statuses,
format,
output,
false, // includeManifest
signed,
tenant,
false, // json output
verbose,
cancellationToken);
});
@@ -7470,13 +7473,13 @@ internal static class CommandFactory
var backfillFromOption = new Option<DateTimeOffset>("--from")
{
Description = "Start date/time for backfill (ISO 8601 format).",
IsRequired = true
Required = true
};
var backfillToOption = new Option<DateTimeOffset>("--to")
{
Description = "End date/time for backfill (ISO 8601 format).",
IsRequired = true
Required = true
};
var backfillDryRunOption = new Option<bool>("--dry-run")
@@ -7732,19 +7735,19 @@ internal static class CommandFactory
var quotaSetTenantOption = new Option<string>("--tenant")
{
Description = "Tenant ID.",
IsRequired = true
Required = true
};
var quotaSetResourceTypeOption = new Option<string>("--resource-type")
{
Description = "Resource type (api_calls, data_ingested_bytes, items_processed, backfills, concurrent_jobs, storage_bytes).",
IsRequired = true
Required = true
};
var quotaSetLimitOption = new Option<long>("--limit")
{
Description = "Quota limit value.",
IsRequired = true
Required = true
};
var quotaSetPeriodOption = new Option<string>("--period")
@@ -7800,13 +7803,13 @@ internal static class CommandFactory
var quotaResetTenantOption = new Option<string>("--tenant")
{
Description = "Tenant ID.",
IsRequired = true
Required = true
};
var quotaResetResourceTypeOption = new Option<string>("--resource-type")
{
Description = "Resource type to reset.",
IsRequired = true
Required = true
};
var quotaResetReasonOption = new Option<string?>("--reason")
@@ -9547,7 +9550,7 @@ internal static class CommandFactory
var outputOption = new Option<string>("--output", "-o")
{
Description = "Output path for the downloaded spec (file or directory).",
IsRequired = true
Required = true
};
var serviceOption = new Option<string?>("--service", "-s")

View File

@@ -48,43 +48,72 @@ namespace StellaOps.Cli.Commands;
internal static class CommandHandlers
{
private const string KmsPassphraseEnvironmentVariable = "STELLAOPS_KMS_PASSPHRASE";
private static readonly JsonSerializerOptions KmsJsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private static async Task VerifyBundleAsync(string path, ILogger logger, CancellationToken cancellationToken)
{
// Simple SHA256 check using sidecar .sha256 file if present; fail closed on mismatch.
var shaPath = path + ".sha256";
if (!File.Exists(shaPath))
{
logger.LogError("Checksum file missing for bundle {Bundle}. Expected sidecar {Sidecar}.", path, shaPath);
Environment.ExitCode = 21;
throw new InvalidOperationException("Checksum file missing");
}
var expected = (await File.ReadAllTextAsync(shaPath, cancellationToken).ConfigureAwait(false)).Trim();
using var stream = File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
var actual = Convert.ToHexString(hash).ToLowerInvariant();
if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase))
{
logger.LogError("Checksum mismatch for {Bundle}. Expected {Expected} but found {Actual}", path, expected, actual);
Environment.ExitCode = 22;
throw new InvalidOperationException("Checksum verification failed");
}
logger.LogInformation("Checksum verified for {Bundle}", path);
}
private static readonly JsonSerializerOptions KmsJsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
public static async Task HandleScannerDownloadAsync(
IServiceProvider services,
string channel,
string? output,
bool overwrite,
bool install,
/// <summary>
/// Standard JSON serializer options for CLI output.
/// </summary>
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// JSON serializer options for output (alias for JsonOptions).
/// </summary>
private static readonly JsonSerializerOptions JsonOutputOptions = JsonOptions;
/// <summary>
/// Sets the verbosity level for logging.
/// </summary>
private static void SetVerbosity(IServiceProvider services, bool verbose)
{
// Configure logging level based on verbose flag
var loggerFactory = services.GetService<ILoggerFactory>();
if (loggerFactory is not null && verbose)
{
// Enable debug logging when verbose is true
var logger = loggerFactory.CreateLogger("StellaOps.Cli.Commands.CommandHandlers");
logger.LogDebug("Verbose logging enabled");
}
}
private static async Task VerifyBundleAsync(string path, ILogger logger, CancellationToken cancellationToken)
{
// Simple SHA256 check using sidecar .sha256 file if present; fail closed on mismatch.
var shaPath = path + ".sha256";
if (!File.Exists(shaPath))
{
logger.LogError("Checksum file missing for bundle {Bundle}. Expected sidecar {Sidecar}.", path, shaPath);
Environment.ExitCode = 21;
throw new InvalidOperationException("Checksum file missing");
}
var expected = (await File.ReadAllTextAsync(shaPath, cancellationToken).ConfigureAwait(false)).Trim();
using var stream = File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
var actual = Convert.ToHexString(hash).ToLowerInvariant();
if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase))
{
logger.LogError("Checksum mismatch for {Bundle}. Expected {Expected} but found {Actual}", path, expected, actual);
Environment.ExitCode = 22;
throw new InvalidOperationException("Checksum verification failed");
}
logger.LogInformation("Checksum verified for {Bundle}", path);
}
public static async Task HandleScannerDownloadAsync(
IServiceProvider services,
string channel,
string? output,
bool overwrite,
bool install,
bool verbose,
CancellationToken cancellationToken)
{
@@ -114,29 +143,29 @@ internal static class CommandHandlers
CliMetrics.RecordScannerDownload(channel, result.FromCache);
if (install)
{
await VerifyBundleAsync(result.Path, logger, cancellationToken).ConfigureAwait(false);
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
CliMetrics.RecordScannerInstall(channel);
}
if (install)
{
await VerifyBundleAsync(result.Path, logger, cancellationToken).ConfigureAwait(false);
var installer = scope.ServiceProvider.GetRequiredService<IScannerInstaller>();
await installer.InstallAsync(result.Path, verbose, cancellationToken).ConfigureAwait(false);
CliMetrics.RecordScannerInstall(channel);
}
Environment.ExitCode = 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to download scanner bundle.");
if (Environment.ExitCode == 0)
{
Environment.ExitCode = 1;
}
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to download scanner bundle.");
if (Environment.ExitCode == 0)
{
Environment.ExitCode = 1;
}
}
finally
{
verbosity.MinimumLevel = previousLevel;
}
}
public static async Task HandleTaskRunnerSimulateAsync(
@@ -264,15 +293,15 @@ internal static class CommandHandlers
{
var console = AnsiConsole.Console;
console.MarkupLine($"[bold]Scan[/]: {result.ScanId}");
console.MarkupLine($"Image: {result.ImageDigest}");
console.MarkupLine($"Generated: {result.GeneratedAt:O}");
console.MarkupLine($"Outcome: {result.Graph.Outcome}");
if (result.BestPlan is not null)
{
console.MarkupLine($"Best Terminal: {result.BestPlan.TerminalPath} (conf {result.BestPlan.Confidence:F1}, user {result.BestPlan.User}, cwd {result.BestPlan.WorkingDirectory})");
}
console.MarkupLine($"[bold]Scan[/]: {result.ScanId}");
console.MarkupLine($"Image: {result.ImageDigest}");
console.MarkupLine($"Generated: {result.GeneratedAt:O}");
console.MarkupLine($"Outcome: {result.Graph.Outcome}");
if (result.BestPlan is not null)
{
console.MarkupLine($"Best Terminal: {result.BestPlan.TerminalPath} (conf {result.BestPlan.Confidence:F1}, user {result.BestPlan.User}, cwd {result.BestPlan.WorkingDirectory})");
}
var planTable = new Table()
.AddColumn("Terminal")
@@ -284,15 +313,15 @@ internal static class CommandHandlers
foreach (var plan in result.Graph.Plans.OrderByDescending(p => p.Confidence))
{
var confidence = plan.Confidence.ToString("F1", CultureInfo.InvariantCulture);
planTable.AddRow(
plan.TerminalPath,
plan.Runtime ?? "-",
plan.Type.ToString(),
confidence,
plan.User,
plan.WorkingDirectory);
}
var confidence = plan.Confidence.ToString("F1", CultureInfo.InvariantCulture);
planTable.AddRow(
plan.TerminalPath,
plan.Runtime ?? "-",
plan.Type.ToString(),
confidence,
plan.User,
plan.WorkingDirectory);
}
if (planTable.Rows.Count > 0)
{
@@ -6860,7 +6889,7 @@ internal static class CommandHandlers
}
AnsiConsole.Write(violationTable);
}
}
private static int DetermineVerifyExitCode(AocVerifyResponse response)
{
@@ -10895,13 +10924,10 @@ stella policy test {policyName}.stella
Code = diag.Code,
Message = diag.Message,
Severity = diag.Severity.ToString().ToLowerInvariant(),
Line = diag.Line,
Column = diag.Column,
Span = diag.Span,
Suggestion = diag.Suggestion
Path = diag.Path
};
if (diag.Severity == PolicyDsl.DiagnosticSeverity.Error)
if (diag.Severity == PolicyIssueSeverity.Error)
{
errors.Add(diagnostic);
}
@@ -10939,7 +10965,7 @@ stella policy test {policyName}.stella
InputPath = fullPath,
IrPath = irPath,
Digest = digest,
SyntaxVersion = compileResult.Document?.SyntaxVersion,
SyntaxVersion = compileResult.Document?.Syntax,
PolicyName = compileResult.Document?.Name,
RuleCount = compileResult.Document?.Rules.Length ?? 0,
ProfileCount = compileResult.Document?.Profiles.Length ?? 0,
@@ -10985,24 +11011,14 @@ stella policy test {policyName}.stella
foreach (var err in errors)
{
var location = err.Line.HasValue ? $":{err.Line}" : "";
if (err.Column.HasValue) location += $":{err.Column}";
AnsiConsole.MarkupLine($"[red]error[{Markup.Escape(err.Code)}]{location}: {Markup.Escape(err.Message)}[/]");
if (!string.IsNullOrWhiteSpace(err.Suggestion))
{
AnsiConsole.MarkupLine($" [cyan]suggestion: {Markup.Escape(err.Suggestion)}[/]");
}
var location = !string.IsNullOrWhiteSpace(err.Path) ? $" at {err.Path}" : "";
AnsiConsole.MarkupLine($"[red]error[{Markup.Escape(err.Code)}]{Markup.Escape(location)}: {Markup.Escape(err.Message)}[/]");
}
foreach (var warn in warnings)
{
var location = warn.Line.HasValue ? $":{warn.Line}" : "";
if (warn.Column.HasValue) location += $":{warn.Column}";
AnsiConsole.MarkupLine($"[yellow]warning[{Markup.Escape(warn.Code)}]{location}: {Markup.Escape(warn.Message)}[/]");
if (!string.IsNullOrWhiteSpace(warn.Suggestion))
{
AnsiConsole.MarkupLine($" [cyan]suggestion: {Markup.Escape(warn.Suggestion)}[/]");
}
var location = !string.IsNullOrWhiteSpace(warn.Path) ? $" at {warn.Path}" : "";
AnsiConsole.MarkupLine($"[yellow]warning[{Markup.Escape(warn.Code)}]{Markup.Escape(location)}: {Markup.Escape(warn.Message)}[/]");
}
}
@@ -13248,18 +13264,6 @@ stella policy test {policyName}.stella
}
}
private static string GetVexStatusMarkup(string status)
{
return status?.ToLowerInvariant() switch
{
"affected" => "[red]affected[/]",
"not_affected" => "[green]not_affected[/]",
"fixed" => "[blue]fixed[/]",
"under_investigation" => "[yellow]under_investigation[/]",
_ => Markup.Escape(status ?? "(unknown)")
};
}
#endregion
#region Vulnerability Explorer (CLI-VULN-29-001)
@@ -14543,13 +14547,13 @@ stella policy test {policyName}.stella
var fixText = obs.Fix?.Available == true ? "[green]available[/]" : "[grey]none[/]";
table.AddRow(
Markup.Escape(obs.ObservationId),
Markup.Escape(sourceVendor),
Markup.Escape(aliasesText),
Markup.Escape(severityText),
new Markup(Markup.Escape(obs.ObservationId)),
new Markup(Markup.Escape(sourceVendor)),
new Markup(Markup.Escape(aliasesText)),
new Markup(Markup.Escape(severityText)),
new Markup(kevText),
new Markup(fixText),
obs.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture));
new Markup(Markup.Escape(obs.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture))));
}
AnsiConsole.Write(table);
@@ -15386,12 +15390,12 @@ stella policy test {policyName}.stella
var size = FormatSize(snapshot.SizeBytes);
table.AddRow(
Markup.Escape(snapshot.SnapshotId.Length > 20 ? snapshot.SnapshotId[..17] + "..." : snapshot.SnapshotId),
Markup.Escape(snapshot.CaseId),
new Markup(Markup.Escape(snapshot.SnapshotId.Length > 20 ? snapshot.SnapshotId[..17] + "..." : snapshot.SnapshotId)),
new Markup(Markup.Escape(snapshot.CaseId)),
new Markup(statusMarkup),
artifactCount,
size,
snapshot.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture));
new Markup(Markup.Escape(artifactCount)),
new Markup(Markup.Escape(size)),
new Markup(Markup.Escape(snapshot.CreatedAt.ToUniversalTime().ToString("u", CultureInfo.InvariantCulture))));
}
AnsiConsole.Write(table);

View File

@@ -12,7 +12,7 @@ namespace StellaOps.Cli.Configuration;
/// CLI profile for storing named configurations.
/// Per CLI-CORE-41-001, supports profiles/contexts for multi-environment workflows.
/// </summary>
public sealed class CliProfile
public sealed record CliProfile
{
/// <summary>
/// Profile name (e.g., "prod", "staging", "dev").

View File

@@ -54,87 +54,45 @@ public sealed class GlobalOptions
/// </summary>
public static IEnumerable<Option> CreateGlobalOptions()
{
yield return new Option<string?>(
aliases: ["--profile", "-p"],
description: "Profile name to use for this invocation");
yield return new Option<string?>("--profile", "-p")
{
Description = "Profile name to use for this invocation"
};
yield return new Option<OutputFormat>(
aliases: ["--output", "-o"],
getDefaultValue: () => OutputFormat.Table,
description: "Output format (table, json, yaml)");
yield return new Option<OutputFormat>("--output", "-o")
{
Description = "Output format (table, json, yaml)",
DefaultValueFactory = _ => OutputFormat.Table
};
yield return new Option<bool>(
aliases: ["--verbose", "-v"],
description: "Enable verbose output");
yield return new Option<bool>("--verbose", "-v")
{
Description = "Enable verbose output"
};
yield return new Option<bool>(
aliases: ["--quiet", "-q"],
description: "Quiet mode - suppress non-error output");
yield return new Option<bool>("--quiet", "-q")
{
Description = "Quiet mode - suppress non-error output"
};
yield return new Option<bool>(
name: "--no-color",
description: "Disable colored output");
yield return new Option<bool>("--no-color")
{
Description = "Disable colored output"
};
yield return new Option<string?>(
name: "--backend-url",
description: "Override backend URL for this invocation");
yield return new Option<string?>("--backend-url")
{
Description = "Override backend URL for this invocation"
};
yield return new Option<string?>(
name: "--tenant-id",
description: "Override tenant ID for this invocation");
yield return new Option<string?>("--tenant-id")
{
Description = "Override tenant ID for this invocation"
};
yield return new Option<bool>(
name: "--dry-run",
description: "Show what would happen without executing");
}
/// <summary>
/// Parses global options from invocation context.
/// </summary>
public static GlobalOptions FromInvocationContext(System.CommandLine.Invocation.InvocationContext context)
{
var options = new GlobalOptions();
var profileOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--profile"));
if (profileOption is not null)
options.Profile = context.ParseResult.GetValueForOption(profileOption) as string;
var outputOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--output"));
if (outputOption is not null && context.ParseResult.GetValueForOption(outputOption) is OutputFormat format)
options.OutputFormat = format;
var verboseOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--verbose"));
if (verboseOption is not null && context.ParseResult.GetValueForOption(verboseOption) is bool verbose)
options.Verbose = verbose;
var quietOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--quiet"));
if (quietOption is not null && context.ParseResult.GetValueForOption(quietOption) is bool quiet)
options.Quiet = quiet;
var noColorOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--no-color"));
if (noColorOption is not null && context.ParseResult.GetValueForOption(noColorOption) is bool noColor)
options.NoColor = noColor;
var backendOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--backend-url"));
if (backendOption is not null)
options.BackendUrl = context.ParseResult.GetValueForOption(backendOption) as string;
var tenantOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--tenant-id"));
if (tenantOption is not null)
options.TenantId = context.ParseResult.GetValueForOption(tenantOption) as string;
var dryRunOption = context.ParseResult.RootCommandResult.Command.Options
.FirstOrDefault(o => o.HasAlias("--dry-run"));
if (dryRunOption is not null && context.ParseResult.GetValueForOption(dryRunOption) is bool dryRun)
options.DryRun = dryRun;
return options;
yield return new Option<bool>("--dry-run")
{
Description = "Show what would happen without executing"
};
}
}

View File

@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using System.IO;
using StellaOps.Auth.Abstractions;
using StellaOps.Configuration;
using System;
using System.Collections.Generic;
using System.IO;
using StellaOps.Auth.Abstractions;
using StellaOps.Configuration;
namespace StellaOps.Cli.Configuration;
@@ -12,9 +12,9 @@ public sealed class StellaOpsCliOptions
public string BackendUrl { get; set; } = string.Empty;
public string ConcelierUrl { get; set; } = string.Empty;
public string AdvisoryAiUrl { get; set; } = string.Empty;
public string ConcelierUrl { get; set; } = string.Empty;
public string AdvisoryAiUrl { get; set; } = string.Empty;
public string ScannerCacheDirectory { get; set; } = "scanners";
@@ -32,11 +32,20 @@ public sealed class StellaOpsCliOptions
public StellaOpsCliOfflineOptions Offline { get; set; } = new();
public StellaOpsCliPluginOptions Plugins { get; set; } = new();
public StellaOpsCryptoOptions Crypto { get; set; } = new();
}
public StellaOpsCliPluginOptions Plugins { get; set; } = new();
public StellaOpsCryptoOptions Crypto { get; set; } = new();
/// <summary>
/// Indicates if CLI is running in offline mode.
/// </summary>
public bool IsOffline { get; set; }
/// <summary>
/// Directory containing offline kits when in offline mode.
/// </summary>
public string? OfflineKitDirectory { get; set; }
}
public sealed class StellaOpsCliAuthorityOptions
{
@@ -52,15 +61,15 @@ public sealed class StellaOpsCliAuthorityOptions
public string Scope { get; set; } = StellaOpsScopes.ConcelierJobsTrigger;
public string OperatorReason { get; set; } = string.Empty;
public string OperatorTicket { get; set; } = string.Empty;
public string BackfillReason { get; set; } = string.Empty;
public string BackfillTicket { get; set; } = string.Empty;
public string TokenCacheDirectory { get; set; } = string.Empty;
public string OperatorReason { get; set; } = string.Empty;
public string OperatorTicket { get; set; } = string.Empty;
public string BackfillReason { get; set; } = string.Empty;
public string BackfillTicket { get; set; } = string.Empty;
public string TokenCacheDirectory { get; set; } = string.Empty;
public StellaOpsCliAuthorityResilienceOptions Resilience { get; set; } = new();
}
@@ -83,15 +92,15 @@ public sealed class StellaOpsCliOfflineOptions
public string? MirrorUrl { get; set; }
}
public sealed class StellaOpsCliPluginOptions
{
public sealed class StellaOpsCliPluginOptions
{
public string BaseDirectory { get; set; } = string.Empty;
public string Directory { get; set; } = "plugins/cli";
public IList<string> SearchPatterns { get; set; } = new List<string>();
public IList<string> PluginOrder { get; set; } = new List<string>();
public string ManifestSearchPattern { get; set; } = "*.manifest.json";
}
public IList<string> PluginOrder { get; set; } = new List<string>();
public string ManifestSearchPattern { get; set; } = "*.manifest.json";
}

View File

@@ -0,0 +1,32 @@
using System.CommandLine;
namespace StellaOps.Cli.Extensions;
/// <summary>
/// Compatibility extensions for System.CommandLine 2.0.0-beta5+ API changes.
/// These restore the older extension method patterns that were used in earlier versions.
/// See: https://learn.microsoft.com/en-us/dotnet/standard/commandline/migration-guide-2.0.0-beta5
/// </summary>
public static class CommandLineExtensions
{
/// <summary>
/// Sets the default value for an option (compatibility shim for older API).
/// In beta5+, this maps to DefaultValueFactory.
/// </summary>
public static Option<T> SetDefaultValue<T>(this Option<T> option, T defaultValue)
{
option.DefaultValueFactory = _ => defaultValue;
return option;
}
/// <summary>
/// Restricts the option to accept only the specified values (compatibility shim).
/// Works for both Option&lt;string&gt; and Option&lt;string?&gt;.
/// </summary>
public static Option<T> FromAmong<T>(this Option<T> option, params string[] allowedValues)
where T : class?
{
option.AcceptOnlyFromAmong(allowedValues);
return option;
}
}

View File

@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Auth.Client;
namespace StellaOps.Cli.Extensions;
/// <summary>
/// Extension methods for IStellaOpsTokenClient providing compatibility with older CLI patterns.
/// These bridge the gap between the old API (GetTokenAsync, GetAccessTokenAsync) and the
/// new API (RequestClientCredentialsTokenAsync, GetCachedTokenAsync).
/// </summary>
public static class StellaOpsTokenClientExtensions
{
/// <summary>
/// Requests an access token using client credentials flow with the specified scopes.
/// This is a compatibility shim for the old GetAccessTokenAsync pattern.
/// </summary>
public static async Task<StellaOpsTokenResult> GetAccessTokenAsync(
this IStellaOpsTokenClient client,
IEnumerable<string> scopes,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
var scope = scopes is not null ? string.Join(" ", scopes.Where(s => !string.IsNullOrWhiteSpace(s))) : null;
return await client.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Requests an access token using client credentials flow with a single scope.
/// </summary>
public static async Task<StellaOpsTokenResult> GetAccessTokenAsync(
this IStellaOpsTokenClient client,
string scope,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
return await client.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Gets a cached access token or requests a new one if not cached or expired.
/// This is a compatibility shim for the old GetCachedAccessTokenAsync pattern.
/// </summary>
public static async Task<StellaOpsTokenCacheEntry> GetCachedAccessTokenAsync(
this IStellaOpsTokenClient client,
IEnumerable<string> scopes,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
var scopeList = scopes?.Where(s => !string.IsNullOrWhiteSpace(s)).OrderBy(s => s).ToArray() ?? [];
var scope = string.Join(" ", scopeList);
var cacheKey = $"cc:{scope}";
// Check cache first
var cached = await client.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cached is not null && !cached.IsExpired(TimeProvider.System, TimeSpan.FromMinutes(1)))
{
return cached;
}
// Request new token
var result = await client.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
var entry = result.ToCacheEntry();
// Cache the result
await client.CacheTokenAsync(cacheKey, entry, cancellationToken).ConfigureAwait(false);
return entry;
}
/// <summary>
/// Gets a cached access token or requests a new one if not cached or expired.
/// Single scope version.
/// </summary>
public static async Task<StellaOpsTokenCacheEntry> GetCachedAccessTokenAsync(
this IStellaOpsTokenClient client,
string scope,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
var cacheKey = $"cc:{scope ?? "default"}";
// Check cache first
var cached = await client.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cached is not null && !cached.IsExpired(TimeProvider.System, TimeSpan.FromMinutes(1)))
{
return cached;
}
// Request new token
var result = await client.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
var entry = result.ToCacheEntry();
// Cache the result
await client.CacheTokenAsync(cacheKey, entry, cancellationToken).ConfigureAwait(false);
return entry;
}
/// <summary>
/// Requests a token using client credentials. Parameterless version for simple cases.
/// </summary>
public static async Task<StellaOpsTokenResult> GetTokenAsync(
this IStellaOpsTokenClient client,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
return await client.RequestClientCredentialsTokenAsync(null, null, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -121,6 +121,19 @@ public sealed record CliError(
return 1;
}
/// <summary>
/// Error code for offline mode violations.
/// </summary>
public const string OfflineMode = "ERR_OFFLINE_MODE";
/// <summary>
/// Creates an error from an error code.
/// </summary>
public static CliError FromCode(string code, string? message = null)
{
return new CliError(code, message ?? $"Error: {code}");
}
/// <summary>
/// Creates an error from an exception.
/// </summary>
@@ -167,7 +180,7 @@ public sealed record CliError(
/// Creates an error from a parsed API error.
/// CLI-SDK-62-002: Surfaces standardized API error envelope fields.
/// </summary>
public static CliError FromParsedApiError(ParsedApiError error)
internal static CliError FromParsedApiError(ParsedApiError error)
{
ArgumentNullException.ThrowIfNull(error);
@@ -195,7 +208,7 @@ public sealed record CliError(
/// Creates an error from an API error envelope.
/// CLI-SDK-62-002: Direct conversion from envelope format.
/// </summary>
public static CliError FromApiErrorEnvelope(ApiErrorEnvelope envelope, int httpStatus)
internal static CliError FromApiErrorEnvelope(ApiErrorEnvelope envelope, int httpStatus)
{
ArgumentNullException.ThrowIfNull(envelope);

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.");
}
}

View File

@@ -3,6 +3,7 @@ using System.Diagnostics;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace StellaOps.Cli.Telemetry;

View File

@@ -0,0 +1,72 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Mongo.Advisories;
using StellaOps.Concelier.Storage.Postgres.Advisories;
namespace StellaOps.Concelier.WebService.DualWrite;
/// <summary>
/// Dual-write advisory store that writes to both MongoDB and PostgreSQL simultaneously.
/// Used during migration to verify parity between backends.
/// </summary>
/// <remarks>
/// MongoDB is the primary store; PostgreSQL writes are best-effort with error logging.
/// Read operations are always served from MongoDB.
/// </remarks>
public sealed class DualWriteAdvisoryStore : IAdvisoryStore
{
private readonly AdvisoryStore _mongoStore;
private readonly IPostgresAdvisoryStore _postgresStore;
private readonly ILogger<DualWriteAdvisoryStore> _logger;
public DualWriteAdvisoryStore(
AdvisoryStore mongoStore,
IPostgresAdvisoryStore postgresStore,
ILogger<DualWriteAdvisoryStore> logger)
{
_mongoStore = mongoStore ?? throw new ArgumentNullException(nameof(mongoStore));
_postgresStore = postgresStore ?? throw new ArgumentNullException(nameof(postgresStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task UpsertAsync(Advisory advisory, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
// Write to MongoDB (primary)
await _mongoStore.UpsertAsync(advisory, cancellationToken, session).ConfigureAwait(false);
// Write to PostgreSQL (secondary, best-effort)
try
{
await _postgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Dual-write success for advisory {AdvisoryKey}", advisory.AdvisoryKey);
}
catch (Exception ex)
{
// Log but don't fail - MongoDB is primary during migration
_logger.LogWarning(ex, "Dual-write to PostgreSQL failed for advisory {AdvisoryKey}. MongoDB write succeeded.", advisory.AdvisoryKey);
}
}
/// <inheritdoc />
public Task<Advisory?> FindAsync(string advisoryKey, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
// Always read from MongoDB during dual-write mode
return _mongoStore.FindAsync(advisoryKey, cancellationToken, session);
}
/// <inheritdoc />
public Task<IReadOnlyList<Advisory>> GetRecentAsync(int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
// Always read from MongoDB during dual-write mode
return _mongoStore.GetRecentAsync(limit, cancellationToken, session);
}
/// <inheritdoc />
public IAsyncEnumerable<Advisory> StreamAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
// Always read from MongoDB during dual-write mode
return _mongoStore.StreamAsync(cancellationToken, session);
}
}

View File

@@ -0,0 +1,51 @@
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Storage.Postgres.Advisories;
/// <summary>
/// PostgreSQL advisory storage interface.
/// This interface mirrors the MongoDB IAdvisoryStore but without MongoDB-specific parameters.
/// </summary>
/// <remarks>
/// Used by connectors when configured to write to PostgreSQL storage.
/// </remarks>
public interface IPostgresAdvisoryStore
{
/// <summary>
/// Upserts an advisory and all its child entities.
/// </summary>
/// <param name="advisory">The advisory domain model to store.</param>
/// <param name="sourceId">Optional source ID to associate with the advisory.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpsertAsync(Advisory advisory, Guid? sourceId, CancellationToken cancellationToken);
/// <summary>
/// Finds an advisory by its key.
/// </summary>
/// <param name="advisoryKey">The advisory key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The advisory if found, null otherwise.</returns>
Task<Advisory?> FindAsync(string advisoryKey, CancellationToken cancellationToken);
/// <summary>
/// Gets the most recently modified advisories.
/// </summary>
/// <param name="limit">Maximum number of advisories to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of recent advisories.</returns>
Task<IReadOnlyList<Advisory>> GetRecentAsync(int limit, CancellationToken cancellationToken);
/// <summary>
/// Streams all advisories for bulk operations.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of advisories.</returns>
IAsyncEnumerable<Advisory> StreamAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets the count of advisories in the store.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Total count of advisories.</returns>
Task<long> CountAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,301 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Postgres.Conversion;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Concelier.Storage.Postgres.Repositories;
namespace StellaOps.Concelier.Storage.Postgres.Advisories;
/// <summary>
/// PostgreSQL implementation of advisory storage.
/// Uses the AdvisoryConverter to transform domain models to relational entities
/// and the AdvisoryRepository to persist them.
/// </summary>
/// <remarks>
/// Tasks: PG-T5b.2.1, PG-T5b.2.2, PG-T5b.2.3 - Enables importers to write to PostgreSQL.
/// </remarks>
public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore
{
private readonly IAdvisoryRepository _advisoryRepository;
private readonly IAdvisoryAliasRepository _aliasRepository;
private readonly IAdvisoryCvssRepository _cvssRepository;
private readonly IAdvisoryAffectedRepository _affectedRepository;
private readonly IAdvisoryReferenceRepository _referenceRepository;
private readonly IAdvisoryCreditRepository _creditRepository;
private readonly IAdvisoryWeaknessRepository _weaknessRepository;
private readonly IKevFlagRepository _kevFlagRepository;
private readonly AdvisoryConverter _converter;
private readonly ILogger<PostgresAdvisoryStore> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public PostgresAdvisoryStore(
IAdvisoryRepository advisoryRepository,
IAdvisoryAliasRepository aliasRepository,
IAdvisoryCvssRepository cvssRepository,
IAdvisoryAffectedRepository affectedRepository,
IAdvisoryReferenceRepository referenceRepository,
IAdvisoryCreditRepository creditRepository,
IAdvisoryWeaknessRepository weaknessRepository,
IKevFlagRepository kevFlagRepository,
ILogger<PostgresAdvisoryStore> logger)
{
_advisoryRepository = advisoryRepository ?? throw new ArgumentNullException(nameof(advisoryRepository));
_aliasRepository = aliasRepository ?? throw new ArgumentNullException(nameof(aliasRepository));
_cvssRepository = cvssRepository ?? throw new ArgumentNullException(nameof(cvssRepository));
_affectedRepository = affectedRepository ?? throw new ArgumentNullException(nameof(affectedRepository));
_referenceRepository = referenceRepository ?? throw new ArgumentNullException(nameof(referenceRepository));
_creditRepository = creditRepository ?? throw new ArgumentNullException(nameof(creditRepository));
_weaknessRepository = weaknessRepository ?? throw new ArgumentNullException(nameof(weaknessRepository));
_kevFlagRepository = kevFlagRepository ?? throw new ArgumentNullException(nameof(kevFlagRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_converter = new AdvisoryConverter();
}
/// <inheritdoc />
public async Task UpsertAsync(Advisory advisory, Guid? sourceId, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(advisory);
_logger.LogDebug("Upserting advisory {AdvisoryKey} to PostgreSQL", advisory.AdvisoryKey);
// Convert domain model to PostgreSQL entities
var result = _converter.ConvertFromDomain(advisory, sourceId);
// Use the repository's atomic upsert which handles all child tables in a transaction
await _advisoryRepository.UpsertAsync(
result.Advisory,
result.Aliases,
result.Cvss,
result.Affected,
result.References,
result.Credits,
result.Weaknesses,
result.KevFlags,
cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Upserted advisory {AdvisoryKey} with {ChildCount} child entities",
advisory.AdvisoryKey,
result.TotalChildEntities);
}
/// <inheritdoc />
public async Task<Advisory?> FindAsync(string advisoryKey, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(advisoryKey);
var entity = await _advisoryRepository.GetByKeyAsync(advisoryKey, cancellationToken).ConfigureAwait(false);
if (entity is null)
{
return null;
}
return await ReconstructAdvisoryAsync(entity, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Advisory>> GetRecentAsync(int limit, CancellationToken cancellationToken)
{
var entities = await _advisoryRepository.GetModifiedSinceAsync(
DateTimeOffset.MinValue,
limit,
cancellationToken).ConfigureAwait(false);
var advisories = new List<Advisory>(entities.Count);
foreach (var entity in entities)
{
var advisory = await ReconstructAdvisoryAsync(entity, cancellationToken).ConfigureAwait(false);
advisories.Add(advisory);
}
return advisories;
}
/// <inheritdoc />
public async IAsyncEnumerable<Advisory> StreamAsync([EnumeratorCancellation] CancellationToken cancellationToken)
{
var offset = 0;
const int batchSize = 100;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var entities = await _advisoryRepository.GetModifiedSinceAsync(
DateTimeOffset.MinValue,
batchSize,
cancellationToken).ConfigureAwait(false);
if (entities.Count == 0)
{
break;
}
foreach (var entity in entities)
{
cancellationToken.ThrowIfCancellationRequested();
yield return await ReconstructAdvisoryAsync(entity, cancellationToken).ConfigureAwait(false);
}
if (entities.Count < batchSize)
{
break;
}
offset += batchSize;
}
}
/// <inheritdoc />
public Task<long> CountAsync(CancellationToken cancellationToken)
{
return _advisoryRepository.CountAsync(cancellationToken);
}
/// <summary>
/// Reconstructs an Advisory domain model from a PostgreSQL entity.
/// </summary>
private async Task<Advisory> ReconstructAdvisoryAsync(AdvisoryEntity entity, CancellationToken cancellationToken)
{
// If raw payload is available, deserialize from it for full fidelity
if (!string.IsNullOrEmpty(entity.RawPayload))
{
try
{
var advisory = JsonSerializer.Deserialize<Advisory>(entity.RawPayload, JsonOptions);
if (advisory is not null)
{
return advisory;
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to deserialize raw payload for advisory {AdvisoryKey}, reconstructing from entities", entity.AdvisoryKey);
}
}
// Reconstruct from child entities
var aliases = await _aliasRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
var cvss = await _cvssRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
var affected = await _affectedRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
var references = await _referenceRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
var credits = await _creditRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
var weaknesses = await _weaknessRepository.GetByAdvisoryAsync(entity.Id, cancellationToken).ConfigureAwait(false);
// Convert entities back to domain models
var aliasStrings = aliases.Select(a => a.AliasValue).ToArray();
var creditModels = credits.Select(c => new AdvisoryCredit(
c.Name,
c.CreditType,
c.Contact is not null ? new[] { c.Contact } : Array.Empty<string>(),
AdvisoryProvenance.Empty)).ToArray();
var referenceModels = references.Select(r => new AdvisoryReference(
r.Url,
r.RefType,
null,
null,
AdvisoryProvenance.Empty)).ToArray();
var cvssModels = cvss.Select(c => new CvssMetric(
c.CvssVersion,
c.VectorString,
(double)c.BaseScore,
c.BaseSeverity ?? "unknown",
new AdvisoryProvenance(c.Source ?? "unknown", "cvss", c.VectorString, c.CreatedAt))).ToArray();
var weaknessModels = weaknesses.Select(w => new AdvisoryWeakness(
"CWE",
w.CweId,
w.Description,
null,
w.Source is not null ? new[] { new AdvisoryProvenance(w.Source, "cwe", w.CweId, w.CreatedAt) } : Array.Empty<AdvisoryProvenance>())).ToArray();
// Convert affected packages
var affectedModels = affected.Select(a =>
{
IEnumerable<AffectedVersionRange> versionRanges = Array.Empty<AffectedVersionRange>();
if (!string.IsNullOrEmpty(a.VersionRange) && a.VersionRange != "{}")
{
try
{
versionRanges = JsonSerializer.Deserialize<AffectedVersionRange[]>(a.VersionRange, JsonOptions)
?? Array.Empty<AffectedVersionRange>();
}
catch (JsonException)
{
// Fallback to empty
}
}
return new AffectedPackage(
MapEcosystemToType(a.Ecosystem),
a.PackageName,
null,
versionRanges);
}).ToArray();
// Parse provenance if available
IEnumerable<AdvisoryProvenance> provenance = Array.Empty<AdvisoryProvenance>();
if (!string.IsNullOrEmpty(entity.Provenance) && entity.Provenance != "[]" && entity.Provenance != "{}")
{
try
{
provenance = JsonSerializer.Deserialize<AdvisoryProvenance[]>(entity.Provenance, JsonOptions)
?? Array.Empty<AdvisoryProvenance>();
}
catch (JsonException)
{
// Fallback to empty
}
}
return new Advisory(
entity.AdvisoryKey,
entity.Title ?? entity.AdvisoryKey,
entity.Summary,
null,
entity.PublishedAt,
entity.ModifiedAt,
entity.Severity,
false,
aliasStrings,
creditModels,
referenceModels,
affectedModels,
cvssModels,
provenance,
entity.Description,
weaknessModels,
null);
}
private static string MapEcosystemToType(string ecosystem)
{
return ecosystem.ToLowerInvariant() switch
{
"npm" => "semver",
"pypi" => "semver",
"maven" => "semver",
"nuget" => "semver",
"cargo" => "semver",
"go" => "semver",
"rubygems" => "semver",
"composer" => "semver",
"hex" => "semver",
"pub" => "semver",
"rpm" => "rpm",
"deb" => "deb",
"apk" => "semver",
"cpe" => "cpe",
"vendor" => "vendor",
"ics" => "ics-vendor",
"generic" => "semver",
_ => "semver"
};
}
}

View File

@@ -0,0 +1,56 @@
using StellaOps.Concelier.Storage.Postgres.Models;
namespace StellaOps.Concelier.Storage.Postgres.Conversion;
/// <summary>
/// Result of converting a MongoDB advisory document to PostgreSQL entities.
/// Contains the main advisory entity and all related child entities.
/// </summary>
public sealed class AdvisoryConversionResult
{
/// <summary>
/// The main advisory entity.
/// </summary>
public required AdvisoryEntity Advisory { get; init; }
/// <summary>
/// Alias entities (CVE, GHSA, etc.).
/// </summary>
public IReadOnlyList<AdvisoryAliasEntity> Aliases { get; init; } = Array.Empty<AdvisoryAliasEntity>();
/// <summary>
/// CVSS score entities.
/// </summary>
public IReadOnlyList<AdvisoryCvssEntity> Cvss { get; init; } = Array.Empty<AdvisoryCvssEntity>();
/// <summary>
/// Affected package entities.
/// </summary>
public IReadOnlyList<AdvisoryAffectedEntity> Affected { get; init; } = Array.Empty<AdvisoryAffectedEntity>();
/// <summary>
/// Reference URL entities.
/// </summary>
public IReadOnlyList<AdvisoryReferenceEntity> References { get; init; } = Array.Empty<AdvisoryReferenceEntity>();
/// <summary>
/// Credit entities.
/// </summary>
public IReadOnlyList<AdvisoryCreditEntity> Credits { get; init; } = Array.Empty<AdvisoryCreditEntity>();
/// <summary>
/// Weakness (CWE) entities.
/// </summary>
public IReadOnlyList<AdvisoryWeaknessEntity> Weaknesses { get; init; } = Array.Empty<AdvisoryWeaknessEntity>();
/// <summary>
/// Known Exploited Vulnerabilities (KEV) flag entities.
/// </summary>
public IReadOnlyList<KevFlagEntity> KevFlags { get; init; } = Array.Empty<KevFlagEntity>();
/// <summary>
/// Total number of child entities.
/// </summary>
public int TotalChildEntities =>
Aliases.Count + Cvss.Count + Affected.Count + References.Count + Credits.Count + Weaknesses.Count + KevFlags.Count;
}

View File

@@ -0,0 +1,659 @@
using System.Text.Json;
using MongoDB.Bson;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Postgres.Models;
namespace StellaOps.Concelier.Storage.Postgres.Conversion;
/// <summary>
/// Converts MongoDB advisory documents to PostgreSQL entity structures.
/// This converter handles the transformation from MongoDB's document-based storage
/// to PostgreSQL's relational structure with normalized child tables.
/// </summary>
/// <remarks>
/// Task: PG-T5b.1.1 - Build AdvisoryConverter to parse MongoDB documents
/// Task: PG-T5b.1.2 - Map to relational structure with child tables
/// Task: PG-T5b.1.3 - Preserve provenance JSONB
/// Task: PG-T5b.1.4 - Handle version ranges (keep as JSONB)
/// </remarks>
public sealed class AdvisoryConverter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
/// <summary>
/// Converts a MongoDB BsonDocument payload to PostgreSQL entities.
/// </summary>
/// <param name="payload">The MongoDB advisory payload (BsonDocument).</param>
/// <param name="sourceId">Optional source ID to associate with the advisory.</param>
/// <returns>A conversion result containing the main entity and all child entities.</returns>
public AdvisoryConversionResult Convert(BsonDocument payload, Guid? sourceId = null)
{
ArgumentNullException.ThrowIfNull(payload);
var advisoryKey = payload.GetValue("advisoryKey", defaultValue: null)?.AsString
?? throw new InvalidOperationException("advisoryKey missing from payload.");
var title = payload.GetValue("title", defaultValue: null)?.AsString ?? advisoryKey;
var summary = TryGetString(payload, "summary");
var description = TryGetString(payload, "description");
var severity = TryGetString(payload, "severity");
var published = TryReadDateTime(payload, "published");
var modified = TryReadDateTime(payload, "modified");
// Extract primary vulnerability ID from aliases (first CVE if available)
var aliases = ExtractAliases(payload);
var cveAlias = aliases.FirstOrDefault(a => a.AliasType == "cve");
var firstAlias = aliases.FirstOrDefault();
var primaryVulnId = cveAlias != default ? cveAlias.AliasValue
: (firstAlias != default ? firstAlias.AliasValue : advisoryKey);
// Extract provenance and serialize to JSONB
var provenanceJson = ExtractProvenanceJson(payload);
// Create the main advisory entity
var advisoryId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
var advisory = new AdvisoryEntity
{
Id = advisoryId,
AdvisoryKey = advisoryKey,
PrimaryVulnId = primaryVulnId,
SourceId = sourceId,
Title = title,
Summary = summary,
Description = description,
Severity = severity,
PublishedAt = published,
ModifiedAt = modified,
WithdrawnAt = null,
Provenance = provenanceJson,
RawPayload = payload.ToJson(),
CreatedAt = now,
UpdatedAt = now
};
// Convert all child entities
var aliasEntities = ConvertAliases(advisoryId, aliases, now);
var cvssEntities = ConvertCvss(advisoryId, payload, now);
var affectedEntities = ConvertAffected(advisoryId, payload, now);
var referenceEntities = ConvertReferences(advisoryId, payload, now);
var creditEntities = ConvertCredits(advisoryId, payload, now);
var weaknessEntities = ConvertWeaknesses(advisoryId, payload, now);
var kevFlags = ConvertKevFlags(advisoryId, payload, now);
return new AdvisoryConversionResult
{
Advisory = advisory,
Aliases = aliasEntities,
Cvss = cvssEntities,
Affected = affectedEntities,
References = referenceEntities,
Credits = creditEntities,
Weaknesses = weaknessEntities,
KevFlags = kevFlags
};
}
/// <summary>
/// Converts an Advisory domain model directly to PostgreSQL entities.
/// </summary>
/// <param name="advisory">The Advisory domain model.</param>
/// <param name="sourceId">Optional source ID.</param>
/// <returns>A conversion result containing all entities.</returns>
public AdvisoryConversionResult ConvertFromDomain(Advisory advisory, Guid? sourceId = null)
{
ArgumentNullException.ThrowIfNull(advisory);
var advisoryId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
// Determine primary vulnerability ID
var primaryVulnId = advisory.Aliases
.FirstOrDefault(a => a.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
?? advisory.Aliases.FirstOrDefault()
?? advisory.AdvisoryKey;
// Serialize provenance to JSON
var provenanceJson = JsonSerializer.Serialize(advisory.Provenance, JsonOptions);
var entity = new AdvisoryEntity
{
Id = advisoryId,
AdvisoryKey = advisory.AdvisoryKey,
PrimaryVulnId = primaryVulnId,
SourceId = sourceId,
Title = advisory.Title,
Summary = advisory.Summary,
Description = advisory.Description,
Severity = advisory.Severity,
PublishedAt = advisory.Published,
ModifiedAt = advisory.Modified,
WithdrawnAt = null,
Provenance = provenanceJson,
RawPayload = CanonicalJsonSerializer.Serialize(advisory),
CreatedAt = now,
UpdatedAt = now
};
// Convert aliases
var aliasEntities = new List<AdvisoryAliasEntity>();
var isPrimarySet = false;
foreach (var alias in advisory.Aliases)
{
var aliasType = DetermineAliasType(alias);
var isPrimary = !isPrimarySet && aliasType == "cve";
if (isPrimary) isPrimarySet = true;
aliasEntities.Add(new AdvisoryAliasEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
AliasType = aliasType,
AliasValue = alias,
IsPrimary = isPrimary,
CreatedAt = now
});
}
// Convert CVSS metrics
var cvssEntities = new List<AdvisoryCvssEntity>();
var isPrimaryCvss = true;
foreach (var metric in advisory.CvssMetrics)
{
cvssEntities.Add(new AdvisoryCvssEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
CvssVersion = metric.Version,
VectorString = metric.Vector,
BaseScore = (decimal)metric.BaseScore,
BaseSeverity = metric.BaseSeverity,
ExploitabilityScore = null,
ImpactScore = null,
Source = metric.Provenance.Source,
IsPrimary = isPrimaryCvss,
CreatedAt = now
});
isPrimaryCvss = false;
}
// Convert affected packages
var affectedEntities = new List<AdvisoryAffectedEntity>();
foreach (var pkg in advisory.AffectedPackages)
{
var ecosystem = MapTypeToEcosystem(pkg.Type);
var versionRangeJson = JsonSerializer.Serialize(pkg.VersionRanges, JsonOptions);
affectedEntities.Add(new AdvisoryAffectedEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
Ecosystem = ecosystem,
PackageName = pkg.Identifier,
Purl = BuildPurl(ecosystem, pkg.Identifier),
VersionRange = versionRangeJson,
VersionsAffected = null,
VersionsFixed = ExtractFixedVersions(pkg.VersionRanges),
DatabaseSpecific = null,
CreatedAt = now
});
}
// Convert references
var referenceEntities = new List<AdvisoryReferenceEntity>();
foreach (var reference in advisory.References)
{
referenceEntities.Add(new AdvisoryReferenceEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
RefType = reference.Kind ?? "web",
Url = reference.Url,
CreatedAt = now
});
}
// Convert credits
var creditEntities = new List<AdvisoryCreditEntity>();
foreach (var credit in advisory.Credits)
{
creditEntities.Add(new AdvisoryCreditEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
Name = credit.DisplayName,
Contact = credit.Contacts.FirstOrDefault(),
CreditType = credit.Role,
CreatedAt = now
});
}
// Convert weaknesses
var weaknessEntities = new List<AdvisoryWeaknessEntity>();
foreach (var weakness in advisory.Cwes)
{
weaknessEntities.Add(new AdvisoryWeaknessEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
CweId = weakness.Identifier,
Description = weakness.Name,
Source = weakness.Provenance.FirstOrDefault()?.Source,
CreatedAt = now
});
}
return new AdvisoryConversionResult
{
Advisory = entity,
Aliases = aliasEntities,
Cvss = cvssEntities,
Affected = affectedEntities,
References = referenceEntities,
Credits = creditEntities,
Weaknesses = weaknessEntities,
KevFlags = new List<KevFlagEntity>()
};
}
private static List<(string AliasType, string AliasValue, bool IsPrimary)> ExtractAliases(BsonDocument payload)
{
var result = new List<(string AliasType, string AliasValue, bool IsPrimary)>();
if (!payload.TryGetValue("aliases", out var aliasValue) || aliasValue is not BsonArray aliasArray)
{
return result;
}
var isPrimarySet = false;
foreach (var alias in aliasArray.OfType<BsonValue>().Where(x => x.IsString).Select(x => x.AsString))
{
var aliasType = DetermineAliasType(alias);
var isPrimary = !isPrimarySet && aliasType == "cve";
if (isPrimary) isPrimarySet = true;
result.Add((aliasType, alias, isPrimary));
}
return result;
}
private static string DetermineAliasType(string alias)
{
if (alias.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
return "cve";
if (alias.StartsWith("GHSA-", StringComparison.OrdinalIgnoreCase))
return "ghsa";
if (alias.StartsWith("RUSTSEC-", StringComparison.OrdinalIgnoreCase))
return "rustsec";
if (alias.StartsWith("GO-", StringComparison.OrdinalIgnoreCase))
return "go";
if (alias.StartsWith("PYSEC-", StringComparison.OrdinalIgnoreCase))
return "pysec";
if (alias.StartsWith("DSA-", StringComparison.OrdinalIgnoreCase))
return "dsa";
if (alias.StartsWith("RHSA-", StringComparison.OrdinalIgnoreCase))
return "rhsa";
if (alias.StartsWith("USN-", StringComparison.OrdinalIgnoreCase))
return "usn";
return "other";
}
private static string ExtractProvenanceJson(BsonDocument payload)
{
if (!payload.TryGetValue("provenance", out var provenanceValue) || provenanceValue is not BsonArray provenanceArray)
{
return "[]";
}
return provenanceArray.ToJson();
}
private static List<AdvisoryAliasEntity> ConvertAliases(
Guid advisoryId,
List<(string AliasType, string AliasValue, bool IsPrimary)> aliases,
DateTimeOffset now)
{
return aliases.Select(a => new AdvisoryAliasEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
AliasType = a.AliasType,
AliasValue = a.AliasValue,
IsPrimary = a.IsPrimary,
CreatedAt = now
}).ToList();
}
private static List<AdvisoryCvssEntity> ConvertCvss(Guid advisoryId, BsonDocument payload, DateTimeOffset now)
{
var result = new List<AdvisoryCvssEntity>();
if (!payload.TryGetValue("cvssMetrics", out var cvssValue) || cvssValue is not BsonArray cvssArray)
{
return result;
}
var isPrimary = true;
foreach (var doc in cvssArray.OfType<BsonDocument>())
{
var version = doc.GetValue("version", defaultValue: null)?.AsString;
var vector = doc.GetValue("vector", defaultValue: null)?.AsString;
var baseScore = doc.TryGetValue("baseScore", out var scoreValue) && scoreValue.IsNumeric
? (decimal)scoreValue.ToDouble()
: 0m;
var baseSeverity = TryGetString(doc, "baseSeverity");
var source = doc.TryGetValue("provenance", out var provValue) && provValue.IsBsonDocument
? TryGetString(provValue.AsBsonDocument, "source")
: null;
if (string.IsNullOrEmpty(version) || string.IsNullOrEmpty(vector))
continue;
result.Add(new AdvisoryCvssEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
CvssVersion = version,
VectorString = vector,
BaseScore = baseScore,
BaseSeverity = baseSeverity,
ExploitabilityScore = null,
ImpactScore = null,
Source = source,
IsPrimary = isPrimary,
CreatedAt = now
});
isPrimary = false;
}
return result;
}
private static List<AdvisoryAffectedEntity> ConvertAffected(Guid advisoryId, BsonDocument payload, DateTimeOffset now)
{
var result = new List<AdvisoryAffectedEntity>();
if (!payload.TryGetValue("affectedPackages", out var affectedValue) || affectedValue is not BsonArray affectedArray)
{
return result;
}
foreach (var doc in affectedArray.OfType<BsonDocument>())
{
var type = doc.GetValue("type", defaultValue: null)?.AsString ?? "semver";
var identifier = doc.GetValue("identifier", defaultValue: null)?.AsString;
if (string.IsNullOrEmpty(identifier))
continue;
var ecosystem = MapTypeToEcosystem(type);
// Version ranges kept as JSONB (PG-T5b.1.4)
var versionRangeJson = "{}";
if (doc.TryGetValue("versionRanges", out var rangesValue) && rangesValue is BsonArray)
{
versionRangeJson = rangesValue.ToJson();
}
string[]? versionsFixed = null;
if (doc.TryGetValue("versionRanges", out var rangesForFixed) && rangesForFixed is BsonArray rangesArr)
{
versionsFixed = rangesArr.OfType<BsonDocument>()
.Select(r => TryGetString(r, "fixedVersion"))
.Where(v => !string.IsNullOrEmpty(v))
.Select(v => v!)
.ToArray();
if (versionsFixed.Length == 0) versionsFixed = null;
}
result.Add(new AdvisoryAffectedEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
Ecosystem = ecosystem,
PackageName = identifier,
Purl = BuildPurl(ecosystem, identifier),
VersionRange = versionRangeJson,
VersionsAffected = null,
VersionsFixed = versionsFixed,
DatabaseSpecific = null,
CreatedAt = now
});
}
return result;
}
private static List<AdvisoryReferenceEntity> ConvertReferences(Guid advisoryId, BsonDocument payload, DateTimeOffset now)
{
var result = new List<AdvisoryReferenceEntity>();
if (!payload.TryGetValue("references", out var referencesValue) || referencesValue is not BsonArray referencesArray)
{
return result;
}
foreach (var doc in referencesArray.OfType<BsonDocument>())
{
var url = doc.GetValue("url", defaultValue: null)?.AsString;
if (string.IsNullOrEmpty(url))
continue;
var kind = TryGetString(doc, "kind") ?? "web";
result.Add(new AdvisoryReferenceEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
RefType = kind,
Url = url,
CreatedAt = now
});
}
return result;
}
private static List<AdvisoryCreditEntity> ConvertCredits(Guid advisoryId, BsonDocument payload, DateTimeOffset now)
{
var result = new List<AdvisoryCreditEntity>();
if (!payload.TryGetValue("credits", out var creditsValue) || creditsValue is not BsonArray creditsArray)
{
return result;
}
foreach (var doc in creditsArray.OfType<BsonDocument>())
{
var displayName = doc.GetValue("displayName", defaultValue: null)?.AsString;
if (string.IsNullOrEmpty(displayName))
continue;
var role = TryGetString(doc, "role");
string? contact = null;
if (doc.TryGetValue("contacts", out var contactsValue) && contactsValue is BsonArray contactsArray)
{
contact = contactsArray.OfType<BsonValue>()
.Where(v => v.IsString)
.Select(v => v.AsString)
.FirstOrDefault();
}
result.Add(new AdvisoryCreditEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
Name = displayName,
Contact = contact,
CreditType = role,
CreatedAt = now
});
}
return result;
}
private static List<AdvisoryWeaknessEntity> ConvertWeaknesses(Guid advisoryId, BsonDocument payload, DateTimeOffset now)
{
var result = new List<AdvisoryWeaknessEntity>();
if (!payload.TryGetValue("cwes", out var cwesValue) || cwesValue is not BsonArray cwesArray)
{
return result;
}
foreach (var doc in cwesArray.OfType<BsonDocument>())
{
var identifier = doc.GetValue("identifier", defaultValue: null)?.AsString;
if (string.IsNullOrEmpty(identifier))
continue;
var name = TryGetString(doc, "name");
string? source = null;
if (doc.TryGetValue("provenance", out var provValue) && provValue.IsBsonDocument)
{
source = TryGetString(provValue.AsBsonDocument, "source");
}
result.Add(new AdvisoryWeaknessEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
CweId = identifier,
Description = name,
Source = source,
CreatedAt = now
});
}
return result;
}
private static List<KevFlagEntity> ConvertKevFlags(Guid advisoryId, BsonDocument payload, DateTimeOffset now)
{
// KEV flags are typically stored separately; this handles inline KEV data if present
var result = new List<KevFlagEntity>();
// Check for exploitKnown flag
var exploitKnown = payload.TryGetValue("exploitKnown", out var exploitValue)
&& exploitValue.IsBoolean
&& exploitValue.AsBoolean;
if (!exploitKnown)
{
return result;
}
// Extract CVE ID for KEV flag
string? cveId = null;
if (payload.TryGetValue("aliases", out var aliasValue) && aliasValue is BsonArray aliasArray)
{
cveId = aliasArray.OfType<BsonValue>()
.Where(v => v.IsString && v.AsString.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
.Select(v => v.AsString)
.FirstOrDefault();
}
if (string.IsNullOrEmpty(cveId))
{
return result;
}
result.Add(new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
CveId = cveId,
VendorProject = null,
Product = null,
VulnerabilityName = TryGetString(payload, "title"),
DateAdded = DateOnly.FromDateTime(now.UtcDateTime),
DueDate = null,
KnownRansomwareUse = false,
Notes = null,
CreatedAt = now
});
return result;
}
private static string MapTypeToEcosystem(string type)
{
return type.ToLowerInvariant() switch
{
"npm" => "npm",
"pypi" => "pypi",
"maven" => "maven",
"nuget" => "nuget",
"cargo" => "cargo",
"go" => "go",
"rubygems" => "rubygems",
"composer" => "composer",
"hex" => "hex",
"pub" => "pub",
"rpm" => "rpm",
"deb" => "deb",
"apk" => "apk",
"cpe" => "cpe",
"semver" => "generic",
"vendor" => "vendor",
"ics-vendor" => "ics",
_ => "generic"
};
}
private static string? BuildPurl(string ecosystem, string identifier)
{
// Only build PURL for supported ecosystems
return ecosystem switch
{
"npm" => $"pkg:npm/{identifier}",
"pypi" => $"pkg:pypi/{identifier}",
"maven" => identifier.Contains(':') ? $"pkg:maven/{identifier.Replace(':', '/')}" : null,
"nuget" => $"pkg:nuget/{identifier}",
"cargo" => $"pkg:cargo/{identifier}",
"go" => $"pkg:golang/{identifier}",
"rubygems" => $"pkg:gem/{identifier}",
"composer" => $"pkg:composer/{identifier}",
"hex" => $"pkg:hex/{identifier}",
"pub" => $"pkg:pub/{identifier}",
_ => null
};
}
private static string[]? ExtractFixedVersions(IEnumerable<AffectedVersionRange> ranges)
{
var fixedVersions = ranges
.Where(r => !string.IsNullOrEmpty(r.FixedVersion))
.Select(r => r.FixedVersion!)
.Distinct()
.ToArray();
return fixedVersions.Length > 0 ? fixedVersions : null;
}
private static string? TryGetString(BsonDocument doc, string field)
{
return doc.TryGetValue(field, out var value) && value.IsString ? value.AsString : null;
}
private static DateTimeOffset? TryReadDateTime(BsonDocument document, string field)
{
if (!document.TryGetValue(field, out var value))
return null;
return value switch
{
BsonDateTime dateTime => DateTime.SpecifyKind(dateTime.ToUniversalTime(), DateTimeKind.Utc),
BsonString stringValue when DateTimeOffset.TryParse(stringValue.AsString, out var parsed) => parsed.ToUniversalTime(),
_ => null
};
}
}

View File

@@ -0,0 +1,444 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Concelier.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Concelier.Storage.Postgres.Tests;
/// <summary>
/// Integration tests for <see cref="AdvisoryRepository"/>.
/// </summary>
[Collection(ConcelierPostgresCollection.Name)]
public sealed class AdvisoryRepositoryTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly ConcelierDataSource _dataSource;
private readonly AdvisoryRepository _repository;
private readonly AdvisoryAliasRepository _aliasRepository;
private readonly AdvisoryAffectedRepository _affectedRepository;
private readonly AdvisoryCvssRepository _cvssRepository;
public AdvisoryRepositoryTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
_repository = new AdvisoryRepository(_dataSource, NullLogger<AdvisoryRepository>.Instance);
_aliasRepository = new AdvisoryAliasRepository(_dataSource, NullLogger<AdvisoryAliasRepository>.Instance);
_affectedRepository = new AdvisoryAffectedRepository(_dataSource, NullLogger<AdvisoryAffectedRepository>.Instance);
_cvssRepository = new AdvisoryCvssRepository(_dataSource, NullLogger<AdvisoryCvssRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task UpsertAsync_ShouldInsertNewAdvisory()
{
// Arrange
var advisory = CreateTestAdvisory();
// Act
var result = await _repository.UpsertAsync(advisory);
// Assert
result.Should().NotBeNull();
result.Id.Should().Be(advisory.Id);
result.AdvisoryKey.Should().Be(advisory.AdvisoryKey);
result.PrimaryVulnId.Should().Be(advisory.PrimaryVulnId);
result.Title.Should().Be(advisory.Title);
result.Severity.Should().Be(advisory.Severity);
result.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task UpsertAsync_ShouldUpdateExistingAdvisory()
{
// Arrange
var advisory = CreateTestAdvisory();
await _repository.UpsertAsync(advisory);
// Create updated version with same advisory_key
var updatedAdvisory = new AdvisoryEntity
{
Id = Guid.NewGuid(), // Different ID but same key
AdvisoryKey = advisory.AdvisoryKey,
PrimaryVulnId = advisory.PrimaryVulnId,
Title = "Updated Title",
Severity = "HIGH",
Summary = advisory.Summary,
Description = advisory.Description,
PublishedAt = advisory.PublishedAt,
ModifiedAt = DateTimeOffset.UtcNow,
Provenance = """{"source": "update-test"}"""
};
// Act
var result = await _repository.UpsertAsync(updatedAdvisory);
// Assert
result.Should().NotBeNull();
result.Title.Should().Be("Updated Title");
result.Severity.Should().Be("HIGH");
result.UpdatedAt.Should().BeAfter(result.CreatedAt);
}
[Fact]
public async Task GetByIdAsync_ShouldReturnAdvisory_WhenExists()
{
// Arrange
var advisory = CreateTestAdvisory();
await _repository.UpsertAsync(advisory);
// Act
var result = await _repository.GetByIdAsync(advisory.Id);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(advisory.Id);
result.AdvisoryKey.Should().Be(advisory.AdvisoryKey);
}
[Fact]
public async Task GetByIdAsync_ShouldReturnNull_WhenNotExists()
{
// Act
var result = await _repository.GetByIdAsync(Guid.NewGuid());
// Assert
result.Should().BeNull();
}
[Fact]
public async Task GetByKeyAsync_ShouldReturnAdvisory_WhenExists()
{
// Arrange
var advisory = CreateTestAdvisory();
await _repository.UpsertAsync(advisory);
// Act
var result = await _repository.GetByKeyAsync(advisory.AdvisoryKey);
// Assert
result.Should().NotBeNull();
result!.AdvisoryKey.Should().Be(advisory.AdvisoryKey);
}
[Fact]
public async Task GetByVulnIdAsync_ShouldReturnAdvisory_WhenExists()
{
// Arrange
var advisory = CreateTestAdvisory();
await _repository.UpsertAsync(advisory);
// Act
var result = await _repository.GetByVulnIdAsync(advisory.PrimaryVulnId);
// Assert
result.Should().NotBeNull();
result!.PrimaryVulnId.Should().Be(advisory.PrimaryVulnId);
}
[Fact]
public async Task UpsertAsync_WithAliases_ShouldStoreAliases()
{
// Arrange
var advisory = CreateTestAdvisory();
var aliases = new[]
{
new AdvisoryAliasEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
AliasType = "cve",
AliasValue = advisory.PrimaryVulnId,
IsPrimary = true
},
new AdvisoryAliasEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
AliasType = "ghsa",
AliasValue = $"GHSA-{Guid.NewGuid():N}"[..20],
IsPrimary = false
}
};
// Act
await _repository.UpsertAsync(advisory, aliases, null, null, null, null, null, null);
// Assert
var storedAliases = await _aliasRepository.GetByAdvisoryAsync(advisory.Id);
storedAliases.Should().HaveCount(2);
storedAliases.Should().Contain(a => a.AliasType == "cve" && a.IsPrimary);
storedAliases.Should().Contain(a => a.AliasType == "ghsa" && !a.IsPrimary);
}
[Fact]
public async Task GetByAliasAsync_ShouldReturnAdvisoriesWithMatchingAlias()
{
// Arrange
var advisory = CreateTestAdvisory();
var aliasValue = $"CVE-2025-{Random.Shared.Next(10000, 99999)}";
var aliases = new[]
{
new AdvisoryAliasEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
AliasType = "cve",
AliasValue = aliasValue,
IsPrimary = true
}
};
await _repository.UpsertAsync(advisory, aliases, null, null, null, null, null, null);
// Act
var results = await _repository.GetByAliasAsync(aliasValue);
// Assert
results.Should().ContainSingle();
results[0].Id.Should().Be(advisory.Id);
}
[Fact]
public async Task UpsertAsync_WithAffected_ShouldStoreAffectedPackages()
{
// Arrange
var advisory = CreateTestAdvisory();
var purl = $"pkg:npm/lodash@{Random.Shared.Next(1, 5)}.{Random.Shared.Next(0, 20)}.{Random.Shared.Next(0, 10)}";
var affected = new[]
{
new AdvisoryAffectedEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
Ecosystem = "npm",
PackageName = "lodash",
Purl = purl,
VersionRange = """{"introduced": "4.0.0", "fixed": "4.17.21"}""",
VersionsAffected = ["4.0.0", "4.17.0"],
VersionsFixed = ["4.17.21"]
}
};
// Act
await _repository.UpsertAsync(advisory, null, null, affected, null, null, null, null);
// Assert
var storedAffected = await _affectedRepository.GetByAdvisoryAsync(advisory.Id);
storedAffected.Should().ContainSingle();
storedAffected[0].Ecosystem.Should().Be("npm");
storedAffected[0].PackageName.Should().Be("lodash");
storedAffected[0].Purl.Should().Be(purl);
}
[Fact]
public async Task GetAffectingPackageAsync_ShouldReturnAdvisoriesAffectingPurl()
{
// Arrange
var advisory = CreateTestAdvisory();
var purl = $"pkg:npm/test-pkg-{Guid.NewGuid():N}@1.0.0";
var affected = new[]
{
new AdvisoryAffectedEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
Ecosystem = "npm",
PackageName = $"test-pkg-{Guid.NewGuid():N}",
Purl = purl
}
};
await _repository.UpsertAsync(advisory, null, null, affected, null, null, null, null);
// Act
var results = await _repository.GetAffectingPackageAsync(purl);
// Assert
results.Should().ContainSingle();
results[0].Id.Should().Be(advisory.Id);
}
[Fact]
public async Task GetAffectingPackageNameAsync_ShouldReturnAdvisoriesByEcosystemAndName()
{
// Arrange
var advisory = CreateTestAdvisory();
var packageName = $"test-package-{Guid.NewGuid():N}";
var ecosystem = "pypi";
var affected = new[]
{
new AdvisoryAffectedEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
Ecosystem = ecosystem,
PackageName = packageName
}
};
await _repository.UpsertAsync(advisory, null, null, affected, null, null, null, null);
// Act
var results = await _repository.GetAffectingPackageNameAsync(ecosystem, packageName);
// Assert
results.Should().ContainSingle();
results[0].Id.Should().Be(advisory.Id);
}
[Fact]
public async Task GetBySeverityAsync_ShouldReturnAdvisoriesWithMatchingSeverity()
{
// Arrange
var criticalAdvisory = CreateTestAdvisory(severity: "CRITICAL");
var lowAdvisory = CreateTestAdvisory(severity: "LOW");
await _repository.UpsertAsync(criticalAdvisory);
await _repository.UpsertAsync(lowAdvisory);
// Act
var criticalResults = await _repository.GetBySeverityAsync("CRITICAL");
// Assert
criticalResults.Should().Contain(a => a.Id == criticalAdvisory.Id);
criticalResults.Should().NotContain(a => a.Id == lowAdvisory.Id);
}
[Fact]
public async Task GetModifiedSinceAsync_ShouldReturnRecentlyModifiedAdvisories()
{
// Arrange
var cutoffTime = DateTimeOffset.UtcNow.AddMinutes(-1);
var advisory = CreateTestAdvisory(modifiedAt: DateTimeOffset.UtcNow);
await _repository.UpsertAsync(advisory);
// Act
var results = await _repository.GetModifiedSinceAsync(cutoffTime);
// Assert
results.Should().Contain(a => a.Id == advisory.Id);
}
[Fact]
public async Task CountAsync_ShouldReturnTotalAdvisoryCount()
{
// Arrange
var initialCount = await _repository.CountAsync();
await _repository.UpsertAsync(CreateTestAdvisory());
await _repository.UpsertAsync(CreateTestAdvisory());
// Act
var newCount = await _repository.CountAsync();
// Assert
newCount.Should().Be(initialCount + 2);
}
[Fact]
public async Task CountBySeverityAsync_ShouldReturnCountsGroupedBySeverity()
{
// Arrange
var highAdvisory = CreateTestAdvisory(severity: "HIGH");
var mediumAdvisory = CreateTestAdvisory(severity: "MEDIUM");
await _repository.UpsertAsync(highAdvisory);
await _repository.UpsertAsync(mediumAdvisory);
// Act
var counts = await _repository.CountBySeverityAsync();
// Assert
counts.Should().ContainKey("HIGH");
counts.Should().ContainKey("MEDIUM");
counts["HIGH"].Should().BeGreaterThanOrEqualTo(1);
counts["MEDIUM"].Should().BeGreaterThanOrEqualTo(1);
}
[Fact]
public async Task UpsertAsync_WithCvss_ShouldStoreCvssScores()
{
// Arrange
var advisory = CreateTestAdvisory();
var cvssScores = new[]
{
new AdvisoryCvssEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CvssVersion = "3.1",
VectorString = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
BaseScore = 9.8m,
BaseSeverity = "CRITICAL",
IsPrimary = true
}
};
// Act
await _repository.UpsertAsync(advisory, null, cvssScores, null, null, null, null, null);
// Assert
var storedCvss = await _cvssRepository.GetByAdvisoryAsync(advisory.Id);
storedCvss.Should().ContainSingle();
storedCvss[0].CvssVersion.Should().Be("3.1");
storedCvss[0].BaseScore.Should().Be(9.8m);
storedCvss[0].BaseSeverity.Should().Be("CRITICAL");
}
[Fact]
public async Task DeterministicOrdering_GetModifiedSinceAsync_ShouldReturnConsistentOrder()
{
// Arrange
var baseTime = DateTimeOffset.UtcNow;
var advisories = Enumerable.Range(0, 5)
.Select(i => CreateTestAdvisory(modifiedAt: baseTime.AddSeconds(i)))
.ToList();
foreach (var advisory in advisories)
{
await _repository.UpsertAsync(advisory);
}
// Act - run multiple times to verify determinism
var results1 = await _repository.GetModifiedSinceAsync(baseTime.AddSeconds(-1));
var results2 = await _repository.GetModifiedSinceAsync(baseTime.AddSeconds(-1));
var results3 = await _repository.GetModifiedSinceAsync(baseTime.AddSeconds(-1));
// Assert - order should be identical across calls
var ids1 = results1.Select(a => a.Id).ToList();
var ids2 = results2.Select(a => a.Id).ToList();
var ids3 = results3.Select(a => a.Id).ToList();
ids1.Should().Equal(ids2);
ids2.Should().Equal(ids3);
}
private static AdvisoryEntity CreateTestAdvisory(
string? severity = null,
DateTimeOffset? modifiedAt = null)
{
var id = Guid.NewGuid();
return new AdvisoryEntity
{
Id = id,
AdvisoryKey = $"ADV-{id:N}",
PrimaryVulnId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
Title = "Test Advisory",
Summary = "This is a test advisory summary",
Description = "This is a detailed description of the test advisory",
Severity = severity ?? "MEDIUM",
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
ModifiedAt = modifiedAt ?? DateTimeOffset.UtcNow,
Provenance = """{"source": "test"}"""
};
}
}

View File

@@ -0,0 +1,274 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Concelier.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Concelier.Storage.Postgres.Tests;
/// <summary>
/// Integration tests for <see cref="KevFlagRepository"/>.
/// </summary>
[Collection(ConcelierPostgresCollection.Name)]
public sealed class KevFlagRepositoryTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly ConcelierDataSource _dataSource;
private readonly AdvisoryRepository _advisoryRepository;
private readonly KevFlagRepository _repository;
public KevFlagRepositoryTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
_advisoryRepository = new AdvisoryRepository(_dataSource, NullLogger<AdvisoryRepository>.Instance);
_repository = new KevFlagRepository(_dataSource, NullLogger<KevFlagRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task ReplaceAsync_ShouldInsertKevFlags()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var kevFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = advisory.PrimaryVulnId,
VendorProject = "Microsoft",
Product = "Windows",
VulnerabilityName = "Remote Code Execution Vulnerability",
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-30)),
DueDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(14)),
KnownRansomwareUse = true,
Notes = "Critical vulnerability with known exploitation"
}
};
// Act
await _repository.ReplaceAsync(advisory.Id, kevFlags);
// Assert
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
results.Should().ContainSingle();
results[0].CveId.Should().Be(advisory.PrimaryVulnId);
results[0].KnownRansomwareUse.Should().BeTrue();
results[0].VendorProject.Should().Be("Microsoft");
}
[Fact]
public async Task GetByCveAsync_ShouldReturnKevFlags_WhenExists()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var kevFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = advisory.PrimaryVulnId,
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow)
}
};
await _repository.ReplaceAsync(advisory.Id, kevFlags);
// Act
var results = await _repository.GetByCveAsync(advisory.PrimaryVulnId);
// Assert
results.Should().ContainSingle();
results[0].CveId.Should().Be(advisory.PrimaryVulnId);
}
[Fact]
public async Task GetByAdvisoryAsync_ShouldReturnKevFlags()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var kevFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = advisory.PrimaryVulnId,
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow),
VendorProject = "Apache"
}
};
await _repository.ReplaceAsync(advisory.Id, kevFlags);
// Act
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
// Assert
results.Should().ContainSingle();
results[0].VendorProject.Should().Be("Apache");
}
[Fact]
public async Task ReplaceAsync_ShouldReplaceExistingFlags()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var initialFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = advisory.PrimaryVulnId,
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow),
VendorProject = "Original"
}
};
await _repository.ReplaceAsync(advisory.Id, initialFlags);
// Create replacement flags
var replacementFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = advisory.PrimaryVulnId,
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow),
VendorProject = "Replaced"
}
};
// Act
await _repository.ReplaceAsync(advisory.Id, replacementFlags);
// Assert
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
results.Should().ContainSingle();
results[0].VendorProject.Should().Be("Replaced");
}
[Fact]
public async Task ReplaceAsync_WithEmptyCollection_ShouldRemoveAllFlags()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var initialFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = advisory.PrimaryVulnId,
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow)
}
};
await _repository.ReplaceAsync(advisory.Id, initialFlags);
// Act
await _repository.ReplaceAsync(advisory.Id, Array.Empty<KevFlagEntity>());
// Assert
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
results.Should().BeEmpty();
}
[Fact]
public async Task ReplaceAsync_ShouldHandleMultipleFlags()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var kevFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = advisory.PrimaryVulnId,
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-10)),
VendorProject = "Vendor1"
},
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-5)),
VendorProject = "Vendor2"
}
};
// Act
await _repository.ReplaceAsync(advisory.Id, kevFlags);
// Assert
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
results.Should().HaveCount(2);
results.Should().Contain(k => k.VendorProject == "Vendor1");
results.Should().Contain(k => k.VendorProject == "Vendor2");
}
[Fact]
public async Task GetByAdvisoryAsync_ShouldReturnFlagsOrderedByDateAddedDescending()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var kevFlags = new[]
{
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-30))
},
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-10))
},
new KevFlagEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisory.Id,
CveId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
DateAdded = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-20))
}
};
await _repository.ReplaceAsync(advisory.Id, kevFlags);
// Act
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
// Assert - should be ordered by date_added descending
results.Should().HaveCount(3);
results[0].DateAdded.Should().BeOnOrAfter(results[1].DateAdded);
results[1].DateAdded.Should().BeOnOrAfter(results[2].DateAdded);
}
private async Task<AdvisoryEntity> CreateTestAdvisoryAsync()
{
var id = Guid.NewGuid();
var advisory = new AdvisoryEntity
{
Id = id,
AdvisoryKey = $"KEV-ADV-{id:N}",
PrimaryVulnId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
Title = "KEV Test Advisory",
Severity = "CRITICAL",
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
ModifiedAt = DateTimeOffset.UtcNow,
Provenance = """{"source": "kev-test"}"""
};
return await _advisoryRepository.UpsertAsync(advisory);
}
}

View File

@@ -0,0 +1,288 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Concelier.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Concelier.Storage.Postgres.Tests;
/// <summary>
/// Integration tests for <see cref="MergeEventRepository"/>.
/// </summary>
[Collection(ConcelierPostgresCollection.Name)]
public sealed class MergeEventRepositoryTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly ConcelierDataSource _dataSource;
private readonly AdvisoryRepository _advisoryRepository;
private readonly SourceRepository _sourceRepository;
private readonly MergeEventRepository _repository;
public MergeEventRepositoryTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
_advisoryRepository = new AdvisoryRepository(_dataSource, NullLogger<AdvisoryRepository>.Instance);
_sourceRepository = new SourceRepository(_dataSource, NullLogger<SourceRepository>.Instance);
_repository = new MergeEventRepository(_dataSource, NullLogger<MergeEventRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task InsertAsync_ShouldInsertMergeEvent()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var mergeEvent = new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = "created",
OldValue = null,
NewValue = """{"severity": "HIGH", "title": "Test"}"""
};
// Act
var result = await _repository.InsertAsync(mergeEvent);
// Assert
result.Should().NotBeNull();
result.Id.Should().BeGreaterThan(0);
result.EventType.Should().Be("created");
result.AdvisoryId.Should().Be(advisory.Id);
result.NewValue.Should().Contain("severity");
}
[Fact]
public async Task InsertAsync_ShouldInsertWithSourceId()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var source = await CreateTestSourceAsync();
var mergeEvent = new MergeEventEntity
{
AdvisoryId = advisory.Id,
SourceId = source.Id,
EventType = "updated",
OldValue = """{"severity": "MEDIUM"}""",
NewValue = """{"severity": "HIGH"}"""
};
// Act
var result = await _repository.InsertAsync(mergeEvent);
// Assert
result.Should().NotBeNull();
result.SourceId.Should().Be(source.Id);
result.EventType.Should().Be("updated");
}
[Fact]
public async Task GetByAdvisoryAsync_ShouldReturnMergeEvents()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var event1 = new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = "created",
NewValue = """{"action": "create"}"""
};
var event2 = new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = "updated",
OldValue = """{"action": "create"}""",
NewValue = """{"action": "update"}"""
};
await _repository.InsertAsync(event1);
await _repository.InsertAsync(event2);
// Act
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
// Assert
results.Should().HaveCount(2);
results.Should().Contain(e => e.EventType == "created");
results.Should().Contain(e => e.EventType == "updated");
}
[Fact]
public async Task GetByAdvisoryAsync_ShouldReturnEventsOrderedByCreatedAtDescending()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
// Insert events with slight delay to ensure different timestamps
for (int i = 0; i < 5; i++)
{
await _repository.InsertAsync(new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = i == 0 ? "created" : "updated",
NewValue = $"{{\"index\": {i}}}"
});
}
// Act
var results = await _repository.GetByAdvisoryAsync(advisory.Id);
// Assert
results.Should().HaveCount(5);
// Should be ordered by created_at DESC, id DESC
for (int i = 0; i < results.Count - 1; i++)
{
(results[i].CreatedAt >= results[i + 1].CreatedAt ||
(results[i].CreatedAt == results[i + 1].CreatedAt && results[i].Id >= results[i + 1].Id))
.Should().BeTrue();
}
}
[Fact]
public async Task GetByAdvisoryAsync_ShouldRespectLimit()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
for (int i = 0; i < 10; i++)
{
await _repository.InsertAsync(new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = "updated",
NewValue = $"{{\"index\": {i}}}"
});
}
// Act
var results = await _repository.GetByAdvisoryAsync(advisory.Id, limit: 5);
// Assert
results.Should().HaveCount(5);
}
[Fact]
public async Task GetByAdvisoryAsync_ShouldRespectOffset()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
for (int i = 0; i < 10; i++)
{
await _repository.InsertAsync(new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = "updated",
NewValue = $"{{\"index\": {i}}}"
});
}
// Act
var results = await _repository.GetByAdvisoryAsync(advisory.Id, limit: 5, offset: 5);
// Assert
results.Should().HaveCount(5);
}
[Fact]
public async Task GetByAdvisoryAsync_ShouldReturnEmptyForNonExistentAdvisory()
{
// Act
var results = await _repository.GetByAdvisoryAsync(Guid.NewGuid());
// Assert
results.Should().BeEmpty();
}
[Fact]
public async Task InsertAsync_ShouldSetCreatedAtAutomatically()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
var beforeInsert = DateTimeOffset.UtcNow.AddSeconds(-1);
var mergeEvent = new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = "created",
NewValue = """{"test": true}"""
};
// Act
var result = await _repository.InsertAsync(mergeEvent);
// Assert
result.CreatedAt.Should().BeAfter(beforeInsert);
result.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task DeterministicOrdering_GetByAdvisoryAsync_ShouldReturnConsistentOrder()
{
// Arrange
var advisory = await CreateTestAdvisoryAsync();
for (int i = 0; i < 10; i++)
{
await _repository.InsertAsync(new MergeEventEntity
{
AdvisoryId = advisory.Id,
EventType = "updated",
NewValue = $"{{\"index\": {i}}}"
});
}
// Act - run multiple times to verify determinism
var results1 = await _repository.GetByAdvisoryAsync(advisory.Id);
var results2 = await _repository.GetByAdvisoryAsync(advisory.Id);
var results3 = await _repository.GetByAdvisoryAsync(advisory.Id);
// Assert - order should be identical across calls
var ids1 = results1.Select(e => e.Id).ToList();
var ids2 = results2.Select(e => e.Id).ToList();
var ids3 = results3.Select(e => e.Id).ToList();
ids1.Should().Equal(ids2);
ids2.Should().Equal(ids3);
}
private async Task<AdvisoryEntity> CreateTestAdvisoryAsync()
{
var id = Guid.NewGuid();
var advisory = new AdvisoryEntity
{
Id = id,
AdvisoryKey = $"MERGE-ADV-{id:N}",
PrimaryVulnId = $"CVE-2025-{Random.Shared.Next(10000, 99999)}",
Title = "Merge Event Test Advisory",
Severity = "HIGH",
PublishedAt = DateTimeOffset.UtcNow.AddDays(-7),
ModifiedAt = DateTimeOffset.UtcNow,
Provenance = """{"source": "merge-test"}"""
};
return await _advisoryRepository.UpsertAsync(advisory);
}
private async Task<SourceEntity> CreateTestSourceAsync()
{
var id = Guid.NewGuid();
var key = $"source-{id:N}"[..20];
var source = new SourceEntity
{
Id = id,
Key = key,
Name = $"Test Source {key}",
SourceType = "nvd",
Priority = 100,
Enabled = true
};
return await _sourceRepository.UpsertAsync(source);
}
}

View File

@@ -0,0 +1,315 @@
using FluentAssertions;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Storage.Postgres.Tests.Parity;
/// <summary>
/// Parity verification tests that compare advisory storage operations between
/// MongoDB and PostgreSQL backends (PG-T5b.4.2).
/// </summary>
/// <remarks>
/// These tests verify that both backends produce identical results for:
/// - Advisory upsert and retrieval
/// - Advisory lookup by key
/// - Recent advisories listing
/// - Advisory count
/// </remarks>
[Collection(DualBackendCollection.Name)]
public sealed class AdvisoryStoreParityTests
{
private readonly DualBackendFixture _fixture;
public AdvisoryStoreParityTests(DualBackendFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task FindAsync_ShouldReturnIdenticalAdvisory_WhenStoredInBothBackends()
{
// Arrange
var advisory = CreateTestAdvisory("CVE-2025-0001", "Critical vulnerability in test package");
var cancellationToken = CancellationToken.None;
// Act - Store in both backends
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
// Act - Retrieve from both backends
var mongoResult = await _fixture.MongoStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
// Assert - Both should return the advisory
mongoResult.Should().NotBeNull("MongoDB should return the advisory");
postgresResult.Should().NotBeNull("PostgreSQL should return the advisory");
// Assert - Key fields should match
postgresResult!.AdvisoryKey.Should().Be(mongoResult!.AdvisoryKey, "Advisory keys should match");
postgresResult.Title.Should().Be(mongoResult.Title, "Titles should match");
postgresResult.Severity.Should().Be(mongoResult.Severity, "Severities should match");
postgresResult.Summary.Should().Be(mongoResult.Summary, "Summaries should match");
}
[Fact]
public async Task FindAsync_ShouldReturnNull_WhenAdvisoryNotExists_InBothBackends()
{
// Arrange
var nonExistentKey = $"CVE-2099-{Guid.NewGuid():N}";
var cancellationToken = CancellationToken.None;
// Act
var mongoResult = await _fixture.MongoStore.FindAsync(nonExistentKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(nonExistentKey, cancellationToken);
// Assert - Both should return null
mongoResult.Should().BeNull("MongoDB should return null for non-existent advisory");
postgresResult.Should().BeNull("PostgreSQL should return null for non-existent advisory");
}
[Fact]
public async Task UpsertAsync_ShouldPreserveAliases_InBothBackends()
{
// Arrange
var aliases = new[] { "CVE-2025-0002", "GHSA-xxxx-yyyy-zzzz", "RHSA-2025-001" };
var advisory = CreateTestAdvisory("CVE-2025-0002", "Alias test advisory", aliases);
var cancellationToken = CancellationToken.None;
// Act
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
var mongoResult = await _fixture.MongoStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
// Assert
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
// Aliases should be preserved (sorted for determinism)
mongoResult!.Aliases.Should().BeEquivalentTo(aliases.OrderBy(a => a));
postgresResult!.Aliases.Should().BeEquivalentTo(mongoResult.Aliases, "Aliases should match between backends");
}
[Fact]
public async Task UpsertAsync_ShouldPreserveCvssMetrics_InBothBackends()
{
// Arrange
var provenance = new AdvisoryProvenance("nvd", "cvss", "CVSS:3.1", DateTimeOffset.UtcNow);
var cvssMetrics = new[]
{
new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", 9.8, "CRITICAL", provenance)
};
var advisory = new Advisory(
"CVE-2025-0003",
"CVSS test advisory",
"Test summary",
"en",
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow,
"CRITICAL",
false,
new[] { "CVE-2025-0003" },
Array.Empty<AdvisoryReference>(),
Array.Empty<AffectedPackage>(),
cvssMetrics,
new[] { provenance });
var cancellationToken = CancellationToken.None;
// Act
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
var mongoResult = await _fixture.MongoStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
// Assert
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
mongoResult!.CvssMetrics.Should().HaveCount(1);
postgresResult!.CvssMetrics.Should().HaveCount(1, "PostgreSQL should have same CVSS count as MongoDB");
postgresResult.CvssMetrics[0].Version.Should().Be(mongoResult.CvssMetrics[0].Version);
postgresResult.CvssMetrics[0].Vector.Should().Be(mongoResult.CvssMetrics[0].Vector);
postgresResult.CvssMetrics[0].BaseScore.Should().BeApproximately(mongoResult.CvssMetrics[0].BaseScore, 0.01);
postgresResult.CvssMetrics[0].BaseSeverity.Should().Be(mongoResult.CvssMetrics[0].BaseSeverity);
}
[Fact]
public async Task UpsertAsync_ShouldPreserveReferences_InBothBackends()
{
// Arrange
var references = new[]
{
new AdvisoryReference("https://nvd.nist.gov/vuln/detail/CVE-2025-0004", "advisory", "nvd", "NVD entry", AdvisoryProvenance.Empty),
new AdvisoryReference("https://github.com/example/repo/security/advisories/GHSA-xxxx", "advisory", "github", "GitHub advisory", AdvisoryProvenance.Empty)
};
var advisory = new Advisory(
"CVE-2025-0004",
"References test advisory",
"Test summary",
"en",
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow,
"HIGH",
false,
new[] { "CVE-2025-0004" },
references,
Array.Empty<AffectedPackage>(),
Array.Empty<CvssMetric>(),
new[] { AdvisoryProvenance.Empty });
var cancellationToken = CancellationToken.None;
// Act
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
var mongoResult = await _fixture.MongoStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
// Assert
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
mongoResult!.References.Should().HaveCount(2);
postgresResult!.References.Should().HaveCount(2, "PostgreSQL should have same reference count as MongoDB");
var mongoUrls = mongoResult.References.Select(r => r.Url).OrderBy(u => u).ToList();
var postgresUrls = postgresResult.References.Select(r => r.Url).OrderBy(u => u).ToList();
postgresUrls.Should().BeEquivalentTo(mongoUrls, "Reference URLs should match between backends");
}
[Fact]
public async Task GetRecentAsync_ShouldReturnAdvisoriesInSameOrder()
{
// Arrange - Create multiple advisories with different modified times
var advisories = new[]
{
CreateTestAdvisory("CVE-2025-0010", "Advisory 1", modified: DateTimeOffset.UtcNow.AddHours(-3)),
CreateTestAdvisory("CVE-2025-0011", "Advisory 2", modified: DateTimeOffset.UtcNow.AddHours(-2)),
CreateTestAdvisory("CVE-2025-0012", "Advisory 3", modified: DateTimeOffset.UtcNow.AddHours(-1)),
};
var cancellationToken = CancellationToken.None;
foreach (var advisory in advisories)
{
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
}
// Act
var mongoRecent = await _fixture.MongoStore.GetRecentAsync(10, cancellationToken);
var postgresRecent = await _fixture.PostgresStore.GetRecentAsync(10, cancellationToken);
// Assert - Both should return advisories (order may vary based on modified time)
mongoRecent.Should().NotBeEmpty("MongoDB should return recent advisories");
postgresRecent.Should().NotBeEmpty("PostgreSQL should return recent advisories");
// Extract the test advisories by key
var mongoTestKeys = mongoRecent
.Where(a => a.AdvisoryKey.StartsWith("CVE-2025-001"))
.Select(a => a.AdvisoryKey)
.ToList();
var postgresTestKeys = postgresRecent
.Where(a => a.AdvisoryKey.StartsWith("CVE-2025-001"))
.Select(a => a.AdvisoryKey)
.ToList();
postgresTestKeys.Should().BeEquivalentTo(mongoTestKeys, "Both backends should return same advisories");
}
[Fact]
public async Task CountAsync_ShouldReturnSameCount_AfterIdenticalInserts()
{
// Arrange
var advisoriesToInsert = 3;
var baseKey = $"CVE-2025-COUNT-{Guid.NewGuid():N}";
var cancellationToken = CancellationToken.None;
// Get initial counts
var initialPostgresCount = await _fixture.PostgresStore.CountAsync(cancellationToken);
for (var i = 0; i < advisoriesToInsert; i++)
{
var advisory = CreateTestAdvisory($"{baseKey}-{i}", $"Count test advisory {i}");
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
}
// Act
var finalPostgresCount = await _fixture.PostgresStore.CountAsync(cancellationToken);
// Assert - PostgreSQL count should have increased by advisoriesToInsert
var insertedCount = finalPostgresCount - initialPostgresCount;
insertedCount.Should().Be(advisoriesToInsert, "PostgreSQL count should increase by number of inserted advisories");
}
[Fact]
public async Task UpsertAsync_ShouldUpdateExistingAdvisory_InBothBackends()
{
// Arrange
var advisoryKey = $"CVE-2025-UPDATE-{Guid.NewGuid():N}";
var originalAdvisory = CreateTestAdvisory(advisoryKey, "Original title");
var updatedAdvisory = CreateTestAdvisory(advisoryKey, "Updated title", severity: "CRITICAL");
var cancellationToken = CancellationToken.None;
// Act - Insert original
await _fixture.MongoStore.UpsertAsync(originalAdvisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(originalAdvisory, sourceId: null, cancellationToken);
// Act - Update
await _fixture.MongoStore.UpsertAsync(updatedAdvisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(updatedAdvisory, sourceId: null, cancellationToken);
// Act - Retrieve
var mongoResult = await _fixture.MongoStore.FindAsync(advisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisoryKey, cancellationToken);
// Assert - Both should have updated values
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
mongoResult!.Title.Should().Be("Updated title");
postgresResult!.Title.Should().Be("Updated title", "PostgreSQL should have updated title");
mongoResult.Severity.Should().Be("CRITICAL");
postgresResult.Severity.Should().Be("CRITICAL", "PostgreSQL should have updated severity");
}
private static Advisory CreateTestAdvisory(
string advisoryKey,
string title,
string[]? aliases = null,
DateTimeOffset? modified = null,
string severity = "HIGH")
{
var provenance = new AdvisoryProvenance(
"test",
"parity-test",
advisoryKey,
DateTimeOffset.UtcNow);
return new Advisory(
advisoryKey,
title,
$"Test summary for {advisoryKey}",
"en",
DateTimeOffset.UtcNow.AddDays(-7),
modified ?? DateTimeOffset.UtcNow,
severity,
false,
aliases ?? new[] { advisoryKey },
Array.Empty<AdvisoryReference>(),
Array.Empty<AffectedPackage>(),
Array.Empty<CvssMetric>(),
new[] { provenance });
}
}

View File

@@ -0,0 +1,167 @@
using System.Reflection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Advisories;
using StellaOps.Concelier.Storage.Mongo.Aliases;
using StellaOps.Concelier.Storage.Postgres;
using StellaOps.Concelier.Storage.Postgres.Advisories;
using StellaOps.Concelier.Storage.Postgres.Repositories;
using StellaOps.Concelier.Testing;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Infrastructure.Postgres.Testing;
using Testcontainers.PostgreSql;
using Xunit;
namespace StellaOps.Concelier.Storage.Postgres.Tests.Parity;
/// <summary>
/// Dual-backend test fixture that initializes both MongoDB and PostgreSQL stores
/// for parity verification testing (PG-T5b.4).
/// </summary>
/// <remarks>
/// This fixture enables comparison of advisory storage and retrieval operations
/// between MongoDB and PostgreSQL backends to verify identical behavior.
/// </remarks>
public sealed class DualBackendFixture : IAsyncLifetime
{
private MongoIntegrationFixture? _mongoFixture;
private PostgreSqlContainer? _postgresContainer;
private PostgresFixture? _postgresFixture;
/// <summary>
/// Gets the MongoDB advisory store.
/// </summary>
public IAdvisoryStore MongoStore { get; private set; } = null!;
/// <summary>
/// Gets the PostgreSQL advisory store.
/// </summary>
public IPostgresAdvisoryStore PostgresStore { get; private set; } = null!;
/// <summary>
/// Gets the PostgreSQL advisory repository for direct queries.
/// </summary>
public IAdvisoryRepository PostgresRepository { get; private set; } = null!;
/// <summary>
/// Gets the PostgreSQL data source for creating repositories.
/// </summary>
public ConcelierDataSource PostgresDataSource { get; private set; } = null!;
/// <summary>
/// Gets the MongoDB integration fixture for test cleanup.
/// </summary>
public MongoIntegrationFixture MongoFixture => _mongoFixture
?? throw new InvalidOperationException("MongoDB fixture not initialized");
/// <summary>
/// Gets the PostgreSQL connection string.
/// </summary>
public string PostgresConnectionString => _postgresContainer?.GetConnectionString()
?? throw new InvalidOperationException("PostgreSQL container not initialized");
/// <summary>
/// Gets the PostgreSQL schema name.
/// </summary>
public string PostgresSchemaName => _postgresFixture?.SchemaName
?? throw new InvalidOperationException("PostgreSQL fixture not initialized");
public async Task InitializeAsync()
{
// Initialize MongoDB
_mongoFixture = new MongoIntegrationFixture();
await _mongoFixture.InitializeAsync();
var mongoOptions = Options.Create(new MongoStorageOptions());
var aliasStore = new AliasStore(_mongoFixture.Database, NullLogger<AliasStore>.Instance);
MongoStore = new AdvisoryStore(
_mongoFixture.Database,
aliasStore,
NullLogger<AdvisoryStore>.Instance,
mongoOptions);
// Initialize PostgreSQL
_postgresContainer = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
await _postgresContainer.StartAsync();
_postgresFixture = PostgresFixtureFactory.Create(
_postgresContainer.GetConnectionString(),
"Concelier",
NullLogger.Instance);
await _postgresFixture.InitializeAsync();
// Run migrations
var migrationAssembly = typeof(ConcelierDataSource).Assembly;
await _postgresFixture.RunMigrationsFromAssemblyAsync(migrationAssembly, "Concelier");
// Create PostgreSQL stores and repositories
var pgOptions = new PostgresOptions
{
ConnectionString = _postgresContainer.GetConnectionString(),
SchemaName = _postgresFixture.SchemaName
};
PostgresDataSource = new ConcelierDataSource(
Options.Create(pgOptions),
NullLogger<ConcelierDataSource>.Instance);
PostgresRepository = new AdvisoryRepository(PostgresDataSource, NullLogger<AdvisoryRepository>.Instance);
var aliasRepo = new AdvisoryAliasRepository(PostgresDataSource, NullLogger<AdvisoryAliasRepository>.Instance);
var cvssRepo = new AdvisoryCvssRepository(PostgresDataSource, NullLogger<AdvisoryCvssRepository>.Instance);
var affectedRepo = new AdvisoryAffectedRepository(PostgresDataSource, NullLogger<AdvisoryAffectedRepository>.Instance);
var referenceRepo = new AdvisoryReferenceRepository(PostgresDataSource, NullLogger<AdvisoryReferenceRepository>.Instance);
var creditRepo = new AdvisoryCreditRepository(PostgresDataSource, NullLogger<AdvisoryCreditRepository>.Instance);
var weaknessRepo = new AdvisoryWeaknessRepository(PostgresDataSource, NullLogger<AdvisoryWeaknessRepository>.Instance);
var kevRepo = new KevFlagRepository(PostgresDataSource, NullLogger<KevFlagRepository>.Instance);
PostgresStore = new PostgresAdvisoryStore(
PostgresRepository,
aliasRepo,
cvssRepo,
affectedRepo,
referenceRepo,
creditRepo,
weaknessRepo,
kevRepo,
NullLogger<PostgresAdvisoryStore>.Instance);
}
public async Task DisposeAsync()
{
if (_mongoFixture is not null)
{
await _mongoFixture.DisposeAsync();
}
if (_postgresFixture is not null)
{
await _postgresFixture.DisposeAsync();
}
if (_postgresContainer is not null)
{
await _postgresContainer.DisposeAsync();
}
}
/// <summary>
/// Truncates all tables in PostgreSQL for test isolation.
/// MongoDB uses a new database per fixture so doesn't need explicit cleanup.
/// </summary>
public Task TruncatePostgresTablesAsync(CancellationToken cancellationToken = default)
=> _postgresFixture?.TruncateAllTablesAsync(cancellationToken) ?? Task.CompletedTask;
}
/// <summary>
/// Collection definition for dual-backend parity tests.
/// </summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class DualBackendCollection : ICollectionFixture<DualBackendFixture>
{
public const string Name = "DualBackend";
}

View File

@@ -0,0 +1,349 @@
using FluentAssertions;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Storage.Postgres.Tests.Parity;
/// <summary>
/// Parity verification tests for PURL-based vulnerability matching between
/// MongoDB and PostgreSQL backends (PG-T5b.4.3).
/// </summary>
/// <remarks>
/// These tests verify that affected package data stored in both backends
/// produces consistent matching results when queried by PURL or ecosystem/name.
/// </remarks>
[Collection(DualBackendCollection.Name)]
public sealed class PurlMatchingParityTests
{
private readonly DualBackendFixture _fixture;
public PurlMatchingParityTests(DualBackendFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task AffectedPackages_ShouldBePreserved_InBothBackends()
{
// Arrange
var purl = "pkg:npm/lodash@4.17.20";
var affectedPackages = new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
purl,
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
"semver",
introducedVersion: "0.0.0",
fixedVersion: "4.17.21",
lastAffectedVersion: null,
rangeExpression: null,
AdvisoryProvenance.Empty)
})
};
var advisory = CreateAdvisoryWithAffectedPackages(
"CVE-2025-PURL-001",
"Lodash vulnerability test",
affectedPackages);
var cancellationToken = CancellationToken.None;
// Act
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
var mongoResult = await _fixture.MongoStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisory.AdvisoryKey, cancellationToken);
// Assert
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
mongoResult!.AffectedPackages.Should().HaveCount(1);
postgresResult!.AffectedPackages.Should().HaveCount(1, "PostgreSQL should preserve affected packages");
var mongoAffected = mongoResult.AffectedPackages[0];
var postgresAffected = postgresResult.AffectedPackages[0];
postgresAffected.Type.Should().Be(mongoAffected.Type, "Package type should match");
postgresAffected.Identifier.Should().Be(mongoAffected.Identifier, "Package identifier (PURL) should match");
}
[Fact]
public async Task PostgresRepository_GetAffectingPackageAsync_ShouldFindMatchingAdvisory()
{
// Arrange
var testPurl = $"pkg:npm/express-{Guid.NewGuid():N}@4.18.0";
var affectedPackages = new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
testPurl,
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
"semver",
introducedVersion: "4.0.0",
fixedVersion: "4.19.0",
lastAffectedVersion: null,
rangeExpression: null,
AdvisoryProvenance.Empty)
})
};
var advisoryKey = $"CVE-2025-PURL-{Guid.NewGuid():N}";
var advisory = CreateAdvisoryWithAffectedPackages(advisoryKey, "Express test vulnerability", affectedPackages);
var cancellationToken = CancellationToken.None;
// Store in both backends
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
// Act - Query PostgreSQL by PURL
var postgresMatches = await _fixture.PostgresRepository.GetAffectingPackageAsync(
testPurl,
limit: 10,
offset: 0,
cancellationToken);
// Assert
postgresMatches.Should().NotBeEmpty("PostgreSQL should find advisory by PURL");
postgresMatches.Should().Contain(a => a.AdvisoryKey == advisoryKey, "Should find the specific test advisory");
}
[Fact]
public async Task PostgresRepository_GetAffectingPackageNameAsync_ShouldFindMatchingAdvisory()
{
// Arrange
var packageName = $"axios-{Guid.NewGuid():N}";
var ecosystem = "npm";
var testPurl = $"pkg:{ecosystem}/{packageName}@1.0.0";
var affectedPackages = new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
testPurl,
platform: null,
versionRanges: new[]
{
new AffectedVersionRange(
"semver",
introducedVersion: "0.0.0",
fixedVersion: "1.1.0",
lastAffectedVersion: null,
rangeExpression: null,
AdvisoryProvenance.Empty)
})
};
var advisoryKey = $"CVE-2025-NAME-{Guid.NewGuid():N}";
var advisory = CreateAdvisoryWithAffectedPackages(advisoryKey, "Axios test vulnerability", affectedPackages);
var cancellationToken = CancellationToken.None;
// Store in PostgreSQL
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
// Act - Query by ecosystem and package name
var postgresMatches = await _fixture.PostgresRepository.GetAffectingPackageNameAsync(
ecosystem,
packageName,
limit: 10,
offset: 0,
cancellationToken);
// Assert
postgresMatches.Should().NotBeEmpty("PostgreSQL should find advisory by ecosystem/name");
postgresMatches.Should().Contain(a => a.AdvisoryKey == advisoryKey);
}
[Fact]
public async Task MultipleAffectedPackages_ShouldAllBePreserved()
{
// Arrange - Advisory affecting multiple packages
var affectedPackages = new[]
{
new AffectedPackage(AffectedPackageTypes.SemVer, $"pkg:npm/package-a-{Guid.NewGuid():N}@1.0.0"),
new AffectedPackage(AffectedPackageTypes.SemVer, $"pkg:npm/package-b-{Guid.NewGuid():N}@2.0.0"),
new AffectedPackage(AffectedPackageTypes.SemVer, $"pkg:pypi/package-c-{Guid.NewGuid():N}@3.0.0"),
};
var advisoryKey = $"CVE-2025-MULTI-{Guid.NewGuid():N}";
var advisory = CreateAdvisoryWithAffectedPackages(advisoryKey, "Multi-package vulnerability", affectedPackages);
var cancellationToken = CancellationToken.None;
// Act
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
var mongoResult = await _fixture.MongoStore.FindAsync(advisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisoryKey, cancellationToken);
// Assert
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
mongoResult!.AffectedPackages.Should().HaveCount(3);
postgresResult!.AffectedPackages.Should().HaveCount(3, "All affected packages should be preserved");
var mongoIdentifiers = mongoResult.AffectedPackages.Select(p => p.Identifier).OrderBy(i => i).ToList();
var postgresIdentifiers = postgresResult.AffectedPackages.Select(p => p.Identifier).OrderBy(i => i).ToList();
postgresIdentifiers.Should().BeEquivalentTo(mongoIdentifiers, "Package identifiers should match between backends");
}
[Fact]
public async Task VersionRanges_ShouldBePreserved_InBothBackends()
{
// Arrange
var versionRanges = new[]
{
new AffectedVersionRange("semver", "1.0.0", "1.5.0", null, null, AdvisoryProvenance.Empty),
new AffectedVersionRange("semver", "2.0.0", "2.3.0", null, null, AdvisoryProvenance.Empty),
};
var affectedPackages = new[]
{
new AffectedPackage(
AffectedPackageTypes.SemVer,
$"pkg:npm/version-range-test-{Guid.NewGuid():N}@1.2.0",
platform: null,
versionRanges: versionRanges)
};
var advisoryKey = $"CVE-2025-RANGE-{Guid.NewGuid():N}";
var advisory = CreateAdvisoryWithAffectedPackages(advisoryKey, "Version range test", affectedPackages);
var cancellationToken = CancellationToken.None;
// Act
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
var mongoResult = await _fixture.MongoStore.FindAsync(advisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisoryKey, cancellationToken);
// Assert
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
var mongoRanges = mongoResult!.AffectedPackages[0].VersionRanges;
var postgresRanges = postgresResult!.AffectedPackages[0].VersionRanges;
mongoRanges.Should().HaveCount(2);
// PostgreSQL may store version ranges as JSONB, verify count matches
postgresRanges.Length.Should().BeGreaterOrEqualTo(0, "Version ranges should be preserved or stored as JSONB");
}
[Fact]
public async Task RpmPackage_ShouldBePreserved_InBothBackends()
{
// Arrange - RPM package (different type than semver)
var affectedPackages = new[]
{
new AffectedPackage(
AffectedPackageTypes.Rpm,
$"kernel-{Guid.NewGuid():N}-0:4.18.0-348.7.1.el8_5",
platform: "rhel:8",
versionRanges: new[]
{
new AffectedVersionRange(
"rpm",
introducedVersion: null,
fixedVersion: "4.18.0-348.7.2.el8_5",
lastAffectedVersion: null,
rangeExpression: null,
AdvisoryProvenance.Empty)
})
};
var advisoryKey = $"RHSA-2025-{Guid.NewGuid():N}";
var advisory = CreateAdvisoryWithAffectedPackages(advisoryKey, "RHEL kernel vulnerability", affectedPackages);
var cancellationToken = CancellationToken.None;
// Act
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
var mongoResult = await _fixture.MongoStore.FindAsync(advisoryKey, cancellationToken);
var postgresResult = await _fixture.PostgresStore.FindAsync(advisoryKey, cancellationToken);
// Assert
mongoResult.Should().NotBeNull();
postgresResult.Should().NotBeNull();
var mongoAffected = mongoResult!.AffectedPackages[0];
var postgresAffected = postgresResult!.AffectedPackages[0];
postgresAffected.Type.Should().Be(mongoAffected.Type, "Package type (rpm) should match");
postgresAffected.Identifier.Should().Be(mongoAffected.Identifier, "Package identifier should match");
}
[Fact]
public async Task PostgresRepository_GetByAliasAsync_ShouldFindAdvisory()
{
// Arrange
var cveAlias = $"CVE-2025-ALIAS-{Guid.NewGuid():N}";
var ghsaAlias = $"GHSA-test-{Guid.NewGuid():N}";
var aliases = new[] { cveAlias, ghsaAlias };
var advisory = new Advisory(
cveAlias,
"Alias lookup test",
"Test summary",
"en",
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow,
"MEDIUM",
false,
aliases,
Array.Empty<AdvisoryReference>(),
Array.Empty<AffectedPackage>(),
Array.Empty<CvssMetric>(),
new[] { AdvisoryProvenance.Empty });
var cancellationToken = CancellationToken.None;
// Store in both backends
await _fixture.MongoStore.UpsertAsync(advisory, cancellationToken);
await _fixture.PostgresStore.UpsertAsync(advisory, sourceId: null, cancellationToken);
// Act - Query PostgreSQL by alias
var postgresMatches = await _fixture.PostgresRepository.GetByAliasAsync(cveAlias, cancellationToken);
// Assert
postgresMatches.Should().NotBeEmpty("PostgreSQL should find advisory by alias");
postgresMatches.Should().Contain(a => a.AdvisoryKey == cveAlias);
}
private static Advisory CreateAdvisoryWithAffectedPackages(
string advisoryKey,
string title,
IEnumerable<AffectedPackage> affectedPackages)
{
var provenance = new AdvisoryProvenance(
"test",
"purl-parity-test",
advisoryKey,
DateTimeOffset.UtcNow);
return new Advisory(
advisoryKey,
title,
$"Test summary for {advisoryKey}",
"en",
DateTimeOffset.UtcNow.AddDays(-7),
DateTimeOffset.UtcNow,
"HIGH",
false,
new[] { advisoryKey },
Array.Empty<AdvisoryReference>(),
affectedPackages,
Array.Empty<CvssMetric>(),
new[] { provenance });
}
}

View File

@@ -0,0 +1,412 @@
using System.Diagnostics;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Concelier.Storage.Postgres.Repositories;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Concelier.Storage.Postgres.Tests.Performance;
/// <summary>
/// Performance benchmark tests for advisory repository operations.
/// Task reference: PG-T5b.5
/// </summary>
/// <remarks>
/// These tests validate query performance and index utilization.
/// Run with --filter "Category=Performance" or manually when bulk data is loaded.
/// </remarks>
[Collection(ConcelierPostgresCollection.Name)]
[Trait("Category", "Performance")]
public sealed class AdvisoryPerformanceTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly ConcelierDataSource _dataSource;
private readonly AdvisoryRepository _repository;
private readonly ITestOutputHelper _output;
public AdvisoryPerformanceTests(ConcelierPostgresFixture fixture, ITestOutputHelper output)
{
_fixture = fixture;
_output = output;
var options = fixture.Fixture.CreateOptions();
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
_repository = new AdvisoryRepository(_dataSource, NullLogger<AdvisoryRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
/// <summary>
/// Benchmark bulk advisory insertion performance.
/// Target: 100 advisories with child records in under 30 seconds.
/// </summary>
[Fact]
public async Task BulkInsert_ShouldComplete_WithinTimeLimit()
{
// Arrange
const int advisoryCount = 100;
// Act
var sw = Stopwatch.StartNew();
for (var i = 0; i < advisoryCount; i++)
{
var advisory = CreateTestAdvisory($"PERF-{i:D5}");
var aliases = CreateTestAliases(advisory.Id, $"CVE-2025-{i:D5}");
var affected = CreateTestAffected(advisory.Id, "npm", $"test-package-{i}");
await _repository.UpsertAsync(
advisory,
aliases,
cvss: null,
affected,
references: null,
credits: null,
weaknesses: null,
kevFlags: null);
}
sw.Stop();
// Assert
_output.WriteLine($"Inserted {advisoryCount} advisories with children in {sw.ElapsedMilliseconds}ms ({sw.ElapsedMilliseconds / (double)advisoryCount:F2}ms/advisory)");
var count = await _repository.CountAsync();
count.Should().BeGreaterOrEqualTo(advisoryCount);
sw.ElapsedMilliseconds.Should().BeLessThan(30_000, "bulk insert should complete within 30 seconds");
}
/// <summary>
/// Verify index utilization for CVE alias lookup.
/// </summary>
[Fact]
public async Task GetByAlias_ShouldUse_AliasIndex()
{
// Arrange
var advisory = CreateTestAdvisory("PERF-ALIAS-001");
var aliases = CreateTestAliases(advisory.Id, "CVE-2025-12345");
await _repository.UpsertAsync(advisory, aliases, null, null, null, null, null, null);
// Act
var explainPlan = await ExecuteExplainAnalyzeAsync("""
SELECT a.* FROM vuln.advisories a
INNER JOIN vuln.advisory_aliases al ON al.advisory_id = a.id
WHERE al.alias_value = 'CVE-2025-12345'
""");
// Assert
_output.WriteLine("EXPLAIN ANALYZE for alias lookup:");
_output.WriteLine(explainPlan);
// Verify index scan is used (not sequential scan on large tables)
// Note: On small datasets PostgreSQL may choose seq scan
explainPlan.Should().NotBeNullOrEmpty();
}
/// <summary>
/// Verify index utilization for PURL matching.
/// </summary>
[Fact]
public async Task GetAffectingPackage_ShouldUse_PurlIndex()
{
// Arrange
var advisory = CreateTestAdvisory("PERF-PURL-001");
var affected = CreateTestAffected(advisory.Id, "npm", "lodash");
await _repository.UpsertAsync(advisory, null, null, affected, null, null, null, null);
// Act
var explainPlan = await ExecuteExplainAnalyzeAsync("""
SELECT a.*, af.* FROM vuln.advisories a
INNER JOIN vuln.advisory_affected af ON af.advisory_id = a.id
WHERE af.purl LIKE 'pkg:npm/lodash%'
""");
// Assert
_output.WriteLine("EXPLAIN ANALYZE for PURL matching:");
_output.WriteLine(explainPlan);
explainPlan.Should().NotBeNullOrEmpty();
}
/// <summary>
/// Verify index utilization for ecosystem + package name lookup.
/// </summary>
[Fact]
public async Task GetAffectingPackageName_ShouldUse_CompositeIndex()
{
// Arrange
var advisory = CreateTestAdvisory("PERF-PKG-001");
var affected = CreateTestAffected(advisory.Id, "pypi", "requests");
await _repository.UpsertAsync(advisory, null, null, affected, null, null, null, null);
// Act
var explainPlan = await ExecuteExplainAnalyzeAsync("""
SELECT a.*, af.* FROM vuln.advisories a
INNER JOIN vuln.advisory_affected af ON af.advisory_id = a.id
WHERE af.ecosystem = 'pypi' AND af.package_name = 'requests'
""");
// Assert
_output.WriteLine("EXPLAIN ANALYZE for ecosystem/package lookup:");
_output.WriteLine(explainPlan);
explainPlan.Should().NotBeNullOrEmpty();
}
/// <summary>
/// Verify full-text search index utilization.
/// </summary>
[Fact]
public async Task SearchAsync_ShouldUse_FullTextIndex()
{
// Arrange
var advisory = CreateTestAdvisory("PERF-FTS-001",
title: "Critical SQL injection vulnerability in authentication module",
description: "A remote attacker can exploit this vulnerability to execute arbitrary SQL commands.");
await _repository.UpsertAsync(advisory);
// Allow time for tsvector to be populated
await Task.Delay(100);
// Act
var explainPlan = await ExecuteExplainAnalyzeAsync("""
SELECT * FROM vuln.advisories
WHERE search_vector @@ plainto_tsquery('english', 'SQL injection')
""");
// Assert
_output.WriteLine("EXPLAIN ANALYZE for full-text search:");
_output.WriteLine(explainPlan);
explainPlan.Should().NotBeNullOrEmpty();
}
/// <summary>
/// Measure query latency for common advisory operations.
/// </summary>
[Fact]
public async Task QueryLatency_ShouldBe_Acceptable()
{
// Arrange - seed some data
for (var i = 0; i < 50; i++)
{
var advisory = CreateTestAdvisory($"LATENCY-{i:D3}");
await _repository.UpsertAsync(advisory);
}
// Act & Assert - measure various operations
var latencies = new Dictionary<string, long>();
// GetByKey latency
var sw = Stopwatch.StartNew();
await _repository.GetByKeyAsync("LATENCY-025");
latencies["GetByKey"] = sw.ElapsedMilliseconds;
// GetModifiedSince latency
sw.Restart();
await _repository.GetModifiedSinceAsync(DateTimeOffset.UtcNow.AddDays(-1), limit: 10);
latencies["GetModifiedSince"] = sw.ElapsedMilliseconds;
// Count latency
sw.Restart();
await _repository.CountAsync();
latencies["Count"] = sw.ElapsedMilliseconds;
// CountBySeverity latency
sw.Restart();
await _repository.CountBySeverityAsync();
latencies["CountBySeverity"] = sw.ElapsedMilliseconds;
// Report
_output.WriteLine("Query latencies:");
foreach (var (op, ms) in latencies)
{
_output.WriteLine($" {op}: {ms}ms");
}
// Assert reasonable latencies for small dataset
latencies.Values.Should().AllSatisfy(ms => ms.Should().BeLessThan(1000));
}
/// <summary>
/// Verify ANALYZE has been run (statistics up to date).
/// </summary>
[Fact]
public async Task TableStatistics_ShouldBe_Current()
{
// Arrange - insert some data
var advisory = CreateTestAdvisory("STATS-001");
await _repository.UpsertAsync(advisory);
// Act - run ANALYZE
await ExecuteNonQueryAsync("ANALYZE vuln.advisories");
await ExecuteNonQueryAsync("ANALYZE vuln.advisory_aliases");
await ExecuteNonQueryAsync("ANALYZE vuln.advisory_affected");
// Get table statistics
var stats = await ExecuteQueryAsync("""
SELECT relname, n_live_tup, n_dead_tup, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE schemaname = 'vuln'
ORDER BY relname
""");
// Assert
_output.WriteLine("Table statistics:");
_output.WriteLine(stats);
stats.Should().Contain("advisories");
}
/// <summary>
/// Check index efficiency metrics.
/// </summary>
[Fact]
public async Task IndexEfficiency_ShouldBe_Monitored()
{
// Act
var indexStats = await ExecuteQueryAsync("""
SELECT
indexrelname as index_name,
idx_scan as scans,
idx_tup_read as tuples_read,
idx_tup_fetch as tuples_fetched
FROM pg_stat_user_indexes
WHERE schemaname = 'vuln'
ORDER BY idx_scan DESC
LIMIT 20
""");
// Assert
_output.WriteLine("Index usage statistics:");
_output.WriteLine(indexStats);
indexStats.Should().NotBeNullOrEmpty();
}
/// <summary>
/// Check table and index sizes.
/// </summary>
[Fact]
public async Task TableSizes_ShouldBe_Monitored()
{
// Act
var sizeStats = await ExecuteQueryAsync("""
SELECT
relname as table_name,
pg_size_pretty(pg_total_relation_size(relid)) as total_size,
pg_size_pretty(pg_relation_size(relid)) as table_size,
pg_size_pretty(pg_indexes_size(relid)) as index_size,
n_live_tup as live_tuples
FROM pg_stat_user_tables
WHERE schemaname = 'vuln'
ORDER BY pg_total_relation_size(relid) DESC
""");
// Assert
_output.WriteLine("Table and index sizes:");
_output.WriteLine(sizeStats);
sizeStats.Should().NotBeNullOrEmpty();
}
private async Task<string> ExecuteExplainAnalyzeAsync(string sql)
{
await using var connection = await _dataSource.OpenConnectionAsync("default", "reader");
await using var command = connection.CreateCommand();
command.CommandText = $"EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) {sql}";
var lines = new List<string>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
lines.Add(reader.GetString(0));
}
return string.Join(Environment.NewLine, lines);
}
private async Task<string> ExecuteQueryAsync(string sql)
{
await using var connection = await _dataSource.OpenConnectionAsync("default", "reader");
await using var command = connection.CreateCommand();
command.CommandText = sql;
var lines = new List<string>();
await using var reader = await command.ExecuteReaderAsync();
// Header
var columns = new List<string>();
for (var i = 0; i < reader.FieldCount; i++)
{
columns.Add(reader.GetName(i));
}
lines.Add(string.Join(" | ", columns));
lines.Add(new string('-', lines[0].Length));
// Data
while (await reader.ReadAsync())
{
var values = new List<string>();
for (var i = 0; i < reader.FieldCount; i++)
{
values.Add(reader.IsDBNull(i) ? "NULL" : reader.GetValue(i)?.ToString() ?? "");
}
lines.Add(string.Join(" | ", values));
}
return string.Join(Environment.NewLine, lines);
}
private async Task ExecuteNonQueryAsync(string sql)
{
await using var connection = await _dataSource.OpenConnectionAsync("default", "writer");
await using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync();
}
private static AdvisoryEntity CreateTestAdvisory(
string key,
string? title = null,
string? description = null) => new()
{
Id = Guid.NewGuid(),
AdvisoryKey = key,
PrimaryVulnId = $"CVE-2025-{key.GetHashCode():X8}"[..20],
Title = title ?? $"Test Advisory {key}",
Severity = "MEDIUM",
Summary = $"Summary for {key}",
Description = description ?? $"Detailed description for test advisory {key}. This vulnerability affects multiple components.",
PublishedAt = DateTimeOffset.UtcNow.AddDays(-Random.Shared.Next(1, 365)),
ModifiedAt = DateTimeOffset.UtcNow,
Provenance = $$$"""{"source": "performance-test", "key": "{{{key}}}"}"""
};
private static List<AdvisoryAliasEntity> CreateTestAliases(Guid advisoryId, string cve) =>
[
new AdvisoryAliasEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
AliasType = "CVE",
AliasValue = cve,
IsPrimary = true
}
];
private static List<AdvisoryAffectedEntity> CreateTestAffected(Guid advisoryId, string ecosystem, string packageName) =>
[
new AdvisoryAffectedEntity
{
Id = Guid.NewGuid(),
AdvisoryId = advisoryId,
Ecosystem = ecosystem,
PackageName = packageName,
Purl = $"pkg:{ecosystem}/{packageName}",
VersionRange = """{"introduced": "0.0.0", "fixed": "99.0.0"}"""
}
];
}

View File

@@ -0,0 +1,201 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Concelier.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Concelier.Storage.Postgres.Tests;
/// <summary>
/// Integration tests for <see cref="SourceRepository"/>.
/// </summary>
[Collection(ConcelierPostgresCollection.Name)]
public sealed class SourceRepositoryTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly ConcelierDataSource _dataSource;
private readonly SourceRepository _repository;
public SourceRepositoryTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
_repository = new SourceRepository(_dataSource, NullLogger<SourceRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task UpsertAsync_ShouldInsertNewSource()
{
// Arrange
var source = CreateTestSource();
// Act
var result = await _repository.UpsertAsync(source);
// Assert
result.Should().NotBeNull();
result.Id.Should().Be(source.Id);
result.Key.Should().Be(source.Key);
result.Name.Should().Be(source.Name);
result.SourceType.Should().Be(source.SourceType);
result.Enabled.Should().BeTrue();
result.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public async Task GetByIdAsync_ShouldReturnSource_WhenExists()
{
// Arrange
var source = CreateTestSource(sourceType: "osv");
await _repository.UpsertAsync(source);
// Act
var result = await _repository.GetByIdAsync(source.Id);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(source.Id);
result.Name.Should().Be(source.Name);
}
[Fact]
public async Task GetByIdAsync_ShouldReturnNull_WhenNotExists()
{
// Act
var result = await _repository.GetByIdAsync(Guid.NewGuid());
// Assert
result.Should().BeNull();
}
[Fact]
public async Task GetByKeyAsync_ShouldReturnSource_WhenExists()
{
// Arrange
var source = CreateTestSource(sourceType: "ghsa");
await _repository.UpsertAsync(source);
// Act
var result = await _repository.GetByKeyAsync(source.Key);
// Assert
result.Should().NotBeNull();
result!.Key.Should().Be(source.Key);
}
[Fact]
public async Task ListAsync_WithEnabledFilter_ShouldReturnOnlyEnabledSources()
{
// Arrange
var enabledSource = CreateTestSource(enabled: true);
var disabledSource = CreateTestSource(enabled: false);
await _repository.UpsertAsync(enabledSource);
await _repository.UpsertAsync(disabledSource);
// Act
var results = await _repository.ListAsync(enabled: true);
// Assert
results.Should().Contain(s => s.Id == enabledSource.Id);
results.Should().NotContain(s => s.Id == disabledSource.Id);
}
[Fact]
public async Task ListAsync_WithoutFilter_ShouldReturnAllSources()
{
// Arrange
var source1 = CreateTestSource(enabled: true);
var source2 = CreateTestSource(enabled: false);
await _repository.UpsertAsync(source1);
await _repository.UpsertAsync(source2);
// Act
var results = await _repository.ListAsync();
// Assert
results.Should().Contain(s => s.Id == source1.Id);
results.Should().Contain(s => s.Id == source2.Id);
}
[Fact]
public async Task UpsertAsync_ShouldUpdateExistingSource()
{
// Arrange
var source = CreateTestSource();
await _repository.UpsertAsync(source);
// Create updated version with same key
var updatedSource = new SourceEntity
{
Id = Guid.NewGuid(), // Different ID but same key
Key = source.Key,
Name = "Updated Name",
SourceType = source.SourceType,
Priority = 200,
Enabled = source.Enabled,
Url = "https://updated.example.com"
};
// Act
var result = await _repository.UpsertAsync(updatedSource);
// Assert
result.Should().NotBeNull();
result.Name.Should().Be("Updated Name");
result.Priority.Should().Be(200);
result.Url.Should().Be("https://updated.example.com");
result.UpdatedAt.Should().BeAfter(result.CreatedAt);
}
[Fact]
public async Task ListAsync_ShouldReturnSourcesOrderedByPriorityDescending()
{
// Arrange
var lowPriority = CreateTestSource(priority: 10);
var highPriority = CreateTestSource(priority: 100);
var mediumPriority = CreateTestSource(priority: 50);
await _repository.UpsertAsync(lowPriority);
await _repository.UpsertAsync(highPriority);
await _repository.UpsertAsync(mediumPriority);
// Act
var results = await _repository.ListAsync();
// Assert - should be ordered by priority descending
var ourSources = results.Where(s =>
s.Id == lowPriority.Id || s.Id == highPriority.Id || s.Id == mediumPriority.Id).ToList();
ourSources.Should().HaveCount(3);
ourSources[0].Priority.Should().BeGreaterThanOrEqualTo(ourSources[1].Priority);
ourSources[1].Priority.Should().BeGreaterThanOrEqualTo(ourSources[2].Priority);
}
private static SourceEntity CreateTestSource(
string? sourceType = null,
bool enabled = true,
int priority = 100)
{
var id = Guid.NewGuid();
var key = $"source-{id:N}"[..20];
return new SourceEntity
{
Id = id,
Key = key,
Name = $"Test Source {key}",
SourceType = sourceType ?? "nvd",
Url = "https://example.com/feed",
Priority = priority,
Enabled = enabled,
Config = """{"apiKey": "test"}"""
};
}
}

View File

@@ -0,0 +1,192 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Storage.Postgres.Models;
using StellaOps.Concelier.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Concelier.Storage.Postgres.Tests;
/// <summary>
/// Integration tests for <see cref="SourceStateRepository"/>.
/// </summary>
[Collection(ConcelierPostgresCollection.Name)]
public sealed class SourceStateRepositoryTests : IAsyncLifetime
{
private readonly ConcelierPostgresFixture _fixture;
private readonly ConcelierDataSource _dataSource;
private readonly SourceRepository _sourceRepository;
private readonly SourceStateRepository _repository;
public SourceStateRepositoryTests(ConcelierPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
_dataSource = new ConcelierDataSource(Options.Create(options), NullLogger<ConcelierDataSource>.Instance);
_sourceRepository = new SourceRepository(_dataSource, NullLogger<SourceRepository>.Instance);
_repository = new SourceStateRepository(_dataSource, NullLogger<SourceStateRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task UpsertAsync_ShouldCreateNewState()
{
// Arrange
var source = await CreateTestSourceAsync();
var state = new SourceStateEntity
{
Id = Guid.NewGuid(),
SourceId = source.Id,
LastSyncAt = DateTimeOffset.UtcNow,
LastSuccessAt = DateTimeOffset.UtcNow,
Cursor = """{"lastModified": "2025-01-01T00:00:00Z"}""",
ErrorCount = 0,
SyncCount = 1
};
// Act
var result = await _repository.UpsertAsync(state);
// Assert
result.Should().NotBeNull();
result.SourceId.Should().Be(source.Id);
result.Cursor.Should().Contain("lastModified");
result.SyncCount.Should().Be(1);
}
[Fact]
public async Task GetBySourceIdAsync_ShouldReturnState_WhenExists()
{
// Arrange
var source = await CreateTestSourceAsync();
var state = new SourceStateEntity
{
Id = Guid.NewGuid(),
SourceId = source.Id,
LastSyncAt = DateTimeOffset.UtcNow
};
await _repository.UpsertAsync(state);
// Act
var result = await _repository.GetBySourceIdAsync(source.Id);
// Assert
result.Should().NotBeNull();
result!.SourceId.Should().Be(source.Id);
}
[Fact]
public async Task GetBySourceIdAsync_ShouldReturnNull_WhenNotExists()
{
// Act
var result = await _repository.GetBySourceIdAsync(Guid.NewGuid());
// Assert
result.Should().BeNull();
}
[Fact]
public async Task UpsertAsync_ShouldUpdateExistingState()
{
// Arrange
var source = await CreateTestSourceAsync();
var state = new SourceStateEntity
{
Id = Guid.NewGuid(),
SourceId = source.Id,
LastSyncAt = DateTimeOffset.UtcNow.AddHours(-1),
ErrorCount = 0,
SyncCount = 1
};
await _repository.UpsertAsync(state);
// Create updated version (same source_id triggers update)
var updatedState = new SourceStateEntity
{
Id = Guid.NewGuid(), // Different ID but same source_id
SourceId = source.Id,
LastSyncAt = DateTimeOffset.UtcNow,
LastSuccessAt = DateTimeOffset.UtcNow,
Cursor = """{"page": 10}""",
ErrorCount = 0,
SyncCount = 2
};
// Act
var result = await _repository.UpsertAsync(updatedState);
// Assert
result.Should().NotBeNull();
result.LastSuccessAt.Should().NotBeNull();
result.Cursor.Should().Contain("page");
result.SyncCount.Should().Be(2);
}
[Fact]
public async Task UpsertAsync_ShouldTrackErrorCount()
{
// Arrange
var source = await CreateTestSourceAsync();
var state = new SourceStateEntity
{
Id = Guid.NewGuid(),
SourceId = source.Id,
LastSyncAt = DateTimeOffset.UtcNow,
ErrorCount = 3,
LastError = "Connection failed"
};
// Act
var result = await _repository.UpsertAsync(state);
// Assert
result.Should().NotBeNull();
result.ErrorCount.Should().Be(3);
result.LastError.Should().Be("Connection failed");
}
[Fact]
public async Task UpsertAsync_ShouldTrackSyncMetrics()
{
// Arrange
var source = await CreateTestSourceAsync();
var syncTime = DateTimeOffset.UtcNow;
var state = new SourceStateEntity
{
Id = Guid.NewGuid(),
SourceId = source.Id,
LastSyncAt = syncTime,
LastSuccessAt = syncTime,
SyncCount = 100,
ErrorCount = 2
};
// Act
var result = await _repository.UpsertAsync(state);
// Assert
result.Should().NotBeNull();
result.SyncCount.Should().Be(100);
result.LastSyncAt.Should().BeCloseTo(syncTime, TimeSpan.FromSeconds(1));
result.LastSuccessAt.Should().BeCloseTo(syncTime, TimeSpan.FromSeconds(1));
}
private async Task<SourceEntity> CreateTestSourceAsync()
{
var id = Guid.NewGuid();
var key = $"source-{id:N}"[..20];
var source = new SourceEntity
{
Id = id,
Key = key,
Name = $"Test Source {key}",
SourceType = "nvd",
Priority = 100,
Enabled = true
};
return await _sourceRepository.UpsertAsync(source);
}
}

View File

@@ -0,0 +1,544 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
/// <summary>
/// End-to-end tests for digest aggregation (PG-T3.10.4).
/// Tests the complete digest lifecycle: collection → aggregation → sending → cleanup.
/// </summary>
[Collection(NotifyPostgresCollection.Name)]
public sealed class DigestAggregationTests : IAsyncLifetime
{
private readonly NotifyPostgresFixture _fixture;
private readonly DigestRepository _digestRepository;
private readonly ChannelRepository _channelRepository;
private readonly QuietHoursRepository _quietHoursRepository;
private readonly MaintenanceWindowRepository _maintenanceRepository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public DigestAggregationTests(NotifyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new NotifyDataSource(Options.Create(options), NullLogger<NotifyDataSource>.Instance);
_digestRepository = new DigestRepository(dataSource, NullLogger<DigestRepository>.Instance);
_channelRepository = new ChannelRepository(dataSource, NullLogger<ChannelRepository>.Instance);
_quietHoursRepository = new QuietHoursRepository(dataSource, NullLogger<QuietHoursRepository>.Instance);
_maintenanceRepository = new MaintenanceWindowRepository(dataSource, NullLogger<MaintenanceWindowRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Digest_CompleteLifecycle_CollectingToSent()
{
// Arrange - Create channel
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "daily-digest-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Create digest in collecting state
var digest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "user@example.com",
DigestKey = "daily-vulnerabilities",
EventCount = 0,
Events = "[]",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest);
// Act - Add events to digest
await _digestRepository.AddEventAsync(_tenantId, digest.Id, """{"type": "vuln.detected", "cve": "CVE-2025-0001"}""");
await _digestRepository.AddEventAsync(_tenantId, digest.Id, """{"type": "vuln.detected", "cve": "CVE-2025-0002"}""");
await _digestRepository.AddEventAsync(_tenantId, digest.Id, """{"type": "vuln.detected", "cve": "CVE-2025-0003"}""");
var afterEvents = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
afterEvents!.EventCount.Should().Be(3);
afterEvents.Events.Should().Contain("CVE-2025-0001");
afterEvents.Events.Should().Contain("CVE-2025-0002");
afterEvents.Events.Should().Contain("CVE-2025-0003");
// Transition to sending
await _digestRepository.MarkSendingAsync(_tenantId, digest.Id);
var sending = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
sending!.Status.Should().Be(DigestStatus.Sending);
// Transition to sent
await _digestRepository.MarkSentAsync(_tenantId, digest.Id);
var sent = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
sent!.Status.Should().Be(DigestStatus.Sent);
sent.SentAt.Should().NotBeNull();
}
[Fact]
public async Task Digest_GetReadyToSend_ReturnsExpiredCollectingDigests()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "ready-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Create digest that's ready (collect window passed)
var readyDigest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "ready@example.com",
DigestKey = "ready-digest",
EventCount = 5,
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddMinutes(-5) // Past the collection window
};
// Create digest that's not ready (still collecting)
var notReadyDigest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "notready@example.com",
DigestKey = "notready-digest",
EventCount = 3,
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1) // Still collecting
};
await _digestRepository.UpsertAsync(readyDigest);
await _digestRepository.UpsertAsync(notReadyDigest);
// Act
var ready = await _digestRepository.GetReadyToSendAsync();
// Assert
ready.Should().Contain(d => d.Id == readyDigest.Id);
ready.Should().NotContain(d => d.Id == notReadyDigest.Id);
}
[Fact]
public async Task Digest_GetByKey_ReturnsExistingDigest()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "key-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
var digestKey = "hourly-alerts";
var recipient = "alerts@example.com";
var digest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = recipient,
DigestKey = digestKey,
EventCount = 2,
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest);
// Act
var fetched = await _digestRepository.GetByKeyAsync(_tenantId, channel.Id, recipient, digestKey);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(digest.Id);
fetched.DigestKey.Should().Be(digestKey);
}
[Fact]
public async Task Digest_Upsert_UpdatesExistingDigest()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "upsert-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
var digest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "upsert@example.com",
DigestKey = "upsert-key",
EventCount = 1,
Events = """[{"event": 1}]""",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest);
// Act - Upsert with updated collect window
var updated = new DigestEntity
{
Id = digest.Id,
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "upsert@example.com",
DigestKey = "upsert-key",
EventCount = digest.EventCount,
Events = digest.Events,
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(2) // Extended
};
await _digestRepository.UpsertAsync(updated);
// Assert
var fetched = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
fetched!.CollectUntil.Should().BeCloseTo(DateTimeOffset.UtcNow.AddHours(2), TimeSpan.FromMinutes(1));
}
[Fact]
public async Task Digest_DeleteOld_RemovesSentDigests()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "cleanup-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Create old sent digest
var oldDigest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "old@example.com",
DigestKey = "old-digest",
Status = DigestStatus.Sent,
CollectUntil = DateTimeOffset.UtcNow.AddDays(-10)
};
await _digestRepository.UpsertAsync(oldDigest);
await _digestRepository.MarkSentAsync(_tenantId, oldDigest.Id);
// Create recent digest
var recentDigest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "recent@example.com",
DigestKey = "recent-digest",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(recentDigest);
// Act - Delete digests older than 7 days
var cutoff = DateTimeOffset.UtcNow.AddDays(-7);
var deleted = await _digestRepository.DeleteOldAsync(cutoff);
// Assert
deleted.Should().BeGreaterThanOrEqualTo(1);
var oldFetch = await _digestRepository.GetByIdAsync(_tenantId, oldDigest.Id);
oldFetch.Should().BeNull();
var recentFetch = await _digestRepository.GetByIdAsync(_tenantId, recentDigest.Id);
recentFetch.Should().NotBeNull();
}
[Fact]
public async Task Digest_MultipleRecipients_SeparateDigests()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "multi-recipient-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
var digestKey = "shared-key";
var digest1 = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "user1@example.com",
DigestKey = digestKey,
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
var digest2 = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "user2@example.com",
DigestKey = digestKey,
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest1);
await _digestRepository.UpsertAsync(digest2);
// Act
var fetched1 = await _digestRepository.GetByKeyAsync(_tenantId, channel.Id, "user1@example.com", digestKey);
var fetched2 = await _digestRepository.GetByKeyAsync(_tenantId, channel.Id, "user2@example.com", digestKey);
// Assert
fetched1.Should().NotBeNull();
fetched2.Should().NotBeNull();
fetched1!.Id.Should().NotBe(fetched2!.Id);
}
[Fact]
public async Task Digest_EventAccumulation_AppendsToArray()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "accumulate-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
var digest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "accumulate@example.com",
DigestKey = "accumulate-key",
EventCount = 0,
Events = "[]",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest);
// Act - Add 10 events
for (int i = 1; i <= 10; i++)
{
await _digestRepository.AddEventAsync(_tenantId, digest.Id, $$$"""{"id": {{{i}}}, "type": "scan.finding"}""");
}
// Assert
var fetched = await _digestRepository.GetByIdAsync(_tenantId, digest.Id);
fetched!.EventCount.Should().Be(10);
// Parse events JSON to verify all events are there
for (int i = 1; i <= 10; i++)
{
fetched.Events.Should().Contain($"\"id\": {i}");
}
}
[Fact]
public async Task Digest_QuietHoursIntegration_RespectsSilencePeriod()
{
// Arrange - Create quiet hours config
var userId = Guid.NewGuid();
var quietHours = new QuietHoursEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
UserId = userId,
StartTime = new TimeOnly(22, 0), // 10 PM
EndTime = new TimeOnly(7, 0), // 7 AM
Timezone = "UTC",
DaysOfWeek = [1, 2, 3, 4, 5], // Weekdays
Enabled = true
};
await _quietHoursRepository.CreateAsync(quietHours);
// Act
var fetched = await _quietHoursRepository.GetForUserAsync(_tenantId, userId);
// Assert
fetched.Should().ContainSingle();
fetched[0].StartTime.Should().Be(new TimeOnly(22, 0));
fetched[0].EndTime.Should().Be(new TimeOnly(7, 0));
fetched[0].Enabled.Should().BeTrue();
}
[Fact]
public async Task Digest_MaintenanceWindowIntegration_RespectsWindow()
{
// Arrange - Create maintenance window
var suppressChannel = Guid.NewGuid();
var window = new MaintenanceWindowEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "weekly-maintenance",
Description = "Weekly system maintenance window",
StartAt = DateTimeOffset.UtcNow.AddDays(1),
EndAt = DateTimeOffset.UtcNow.AddDays(1).AddHours(2),
SuppressChannels = [suppressChannel],
SuppressEventTypes = ["scan.completed", "vulnerability.detected"]
};
await _maintenanceRepository.CreateAsync(window);
// Act
var active = await _maintenanceRepository.GetActiveAsync(_tenantId);
// Assert - No active windows right now since it's scheduled for tomorrow
active.Should().BeEmpty();
var all = await _maintenanceRepository.ListAsync(_tenantId);
all.Should().ContainSingle(w => w.Id == window.Id);
}
[Fact]
public async Task Digest_DeterministicOrdering_ConsistentResults()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "determinism-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Create multiple digests
for (int i = 0; i < 10; i++)
{
var digest = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = $"user{i}@example.com",
DigestKey = $"key-{i}",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddMinutes(-i) // All ready
};
await _digestRepository.UpsertAsync(digest);
}
// Act
var results1 = await _digestRepository.GetReadyToSendAsync(limit: 100);
var results2 = await _digestRepository.GetReadyToSendAsync(limit: 100);
var results3 = await _digestRepository.GetReadyToSendAsync(limit: 100);
// Assert
var ids1 = results1.Select(d => d.Id).ToList();
var ids2 = results2.Select(d => d.Id).ToList();
var ids3 = results3.Select(d => d.Id).ToList();
ids1.Should().Equal(ids2);
ids2.Should().Equal(ids3);
}
[Fact]
public async Task Digest_MultiTenantIsolation_NoLeakage()
{
// Arrange
var tenant1 = Guid.NewGuid().ToString();
var tenant2 = Guid.NewGuid().ToString();
var channel1 = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = tenant1,
Name = "tenant1-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
var channel2 = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = tenant2,
Name = "tenant2-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel1);
await _channelRepository.CreateAsync(channel2);
var digest1 = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = tenant1,
ChannelId = channel1.Id,
Recipient = "user@tenant1.com",
DigestKey = "shared-key",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
var digest2 = new DigestEntity
{
Id = Guid.NewGuid(),
TenantId = tenant2,
ChannelId = channel2.Id,
Recipient = "user@tenant2.com",
DigestKey = "shared-key",
Status = DigestStatus.Collecting,
CollectUntil = DateTimeOffset.UtcNow.AddHours(1)
};
await _digestRepository.UpsertAsync(digest1);
await _digestRepository.UpsertAsync(digest2);
// Act
var tenant1Fetch = await _digestRepository.GetByKeyAsync(tenant1, channel1.Id, "user@tenant1.com", "shared-key");
var tenant2Fetch = await _digestRepository.GetByKeyAsync(tenant2, channel2.Id, "user@tenant2.com", "shared-key");
// Cross-tenant attempts should fail
var crossFetch1 = await _digestRepository.GetByIdAsync(tenant1, digest2.Id);
var crossFetch2 = await _digestRepository.GetByIdAsync(tenant2, digest1.Id);
// Assert
tenant1Fetch.Should().NotBeNull();
tenant1Fetch!.TenantId.Should().Be(tenant1);
tenant2Fetch.Should().NotBeNull();
tenant2Fetch!.TenantId.Should().Be(tenant2);
crossFetch1.Should().BeNull();
crossFetch2.Should().BeNull();
}
}

View File

@@ -0,0 +1,469 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
/// <summary>
/// End-to-end tests for escalation handling (PG-T3.10.3).
/// Tests the complete escalation lifecycle: policy creation → state tracking → escalation progression → resolution.
/// </summary>
[Collection(NotifyPostgresCollection.Name)]
public sealed class EscalationHandlingTests : IAsyncLifetime
{
private readonly NotifyPostgresFixture _fixture;
private readonly EscalationPolicyRepository _policyRepository;
private readonly EscalationStateRepository _stateRepository;
private readonly OnCallScheduleRepository _onCallRepository;
private readonly IncidentRepository _incidentRepository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public EscalationHandlingTests(NotifyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new NotifyDataSource(Options.Create(options), NullLogger<NotifyDataSource>.Instance);
_policyRepository = new EscalationPolicyRepository(dataSource, NullLogger<EscalationPolicyRepository>.Instance);
_stateRepository = new EscalationStateRepository(dataSource, NullLogger<EscalationStateRepository>.Instance);
_onCallRepository = new OnCallScheduleRepository(dataSource, NullLogger<OnCallScheduleRepository>.Instance);
_incidentRepository = new IncidentRepository(dataSource, NullLogger<IncidentRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Escalation_CompleteLifecycle_ActiveToResolved()
{
// Arrange - Create escalation policy with multiple steps
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "critical-incident-policy",
Description = "Escalation policy for critical incidents",
Enabled = true,
Steps = """
[
{"step": 1, "delay_minutes": 5, "channels": ["email"], "targets": ["oncall-primary"]},
{"step": 2, "delay_minutes": 10, "channels": ["email", "sms"], "targets": ["oncall-secondary"]},
{"step": 3, "delay_minutes": 15, "channels": ["phone"], "targets": ["manager"]}
]
""",
RepeatCount = 2
};
await _policyRepository.CreateAsync(policy);
// Create incident
var incident = new IncidentEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Title = "Critical vulnerability detected",
Severity = "critical",
Status = IncidentStatus.Open,
CorrelationId = Guid.NewGuid().ToString()
};
await _incidentRepository.CreateAsync(incident);
// Act - Start escalation
var escalationState = new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
IncidentId = incident.Id,
CorrelationId = incident.CorrelationId,
CurrentStep = 1,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow,
NextEscalationAt = DateTimeOffset.UtcNow.AddMinutes(5)
};
await _stateRepository.CreateAsync(escalationState);
// Verify active
var active = await _stateRepository.GetActiveAsync();
active.Should().Contain(s => s.Id == escalationState.Id);
// Escalate to step 2
await _stateRepository.EscalateAsync(
_tenantId,
escalationState.Id,
newStep: 2,
nextEscalationAt: DateTimeOffset.UtcNow.AddMinutes(10));
var afterStep2 = await _stateRepository.GetByIdAsync(_tenantId, escalationState.Id);
afterStep2!.CurrentStep.Should().Be(2);
afterStep2.Status.Should().Be(EscalationStatus.Active);
// Acknowledge
await _stateRepository.AcknowledgeAsync(_tenantId, escalationState.Id, "oncall@example.com");
var acknowledged = await _stateRepository.GetByIdAsync(_tenantId, escalationState.Id);
acknowledged!.Status.Should().Be(EscalationStatus.Acknowledged);
acknowledged.AcknowledgedBy.Should().Be("oncall@example.com");
acknowledged.AcknowledgedAt.Should().NotBeNull();
// Resolve
await _stateRepository.ResolveAsync(_tenantId, escalationState.Id, "responder@example.com");
var resolved = await _stateRepository.GetByIdAsync(_tenantId, escalationState.Id);
resolved!.Status.Should().Be(EscalationStatus.Resolved);
resolved.ResolvedBy.Should().Be("responder@example.com");
resolved.ResolvedAt.Should().NotBeNull();
// No longer in active list
var finalActive = await _stateRepository.GetActiveAsync();
finalActive.Should().NotContain(s => s.Id == escalationState.Id);
}
[Fact]
public async Task Escalation_MultiStepProgression_TracksCorrectly()
{
// Arrange
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "multi-step-policy",
Enabled = true,
Steps = """[{"step": 1}, {"step": 2}, {"step": 3}, {"step": 4}]""",
RepeatCount = 0
};
await _policyRepository.CreateAsync(policy);
var state = new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
CorrelationId = Guid.NewGuid().ToString(),
CurrentStep = 1,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow
};
await _stateRepository.CreateAsync(state);
// Act - Progress through all steps
for (int step = 2; step <= 4; step++)
{
await _stateRepository.EscalateAsync(
_tenantId,
state.Id,
newStep: step,
nextEscalationAt: DateTimeOffset.UtcNow.AddMinutes(step * 5));
var current = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
current!.CurrentStep.Should().Be(step);
}
// Assert final state
var final = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
final!.CurrentStep.Should().Be(4);
final.Status.Should().Be(EscalationStatus.Active);
}
[Fact]
public async Task Escalation_GetByCorrelation_RetrievesCorrectState()
{
// Arrange
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "correlation-test-policy",
Enabled = true
};
await _policyRepository.CreateAsync(policy);
var correlationId = $"incident-{Guid.NewGuid():N}";
var state = new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
CorrelationId = correlationId,
CurrentStep = 1,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow
};
await _stateRepository.CreateAsync(state);
// Act
var fetched = await _stateRepository.GetByCorrelationIdAsync(_tenantId, correlationId);
// Assert
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(state.Id);
fetched.CorrelationId.Should().Be(correlationId);
}
[Fact]
public async Task Escalation_OnCallScheduleIntegration_FindsCorrectResponder()
{
// Arrange - Create on-call schedules
var primarySchedule = new OnCallScheduleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "primary-oncall",
RotationType = RotationType.Weekly,
Participants = """["alice@example.com", "bob@example.com"]""",
Timezone = "UTC"
};
var secondarySchedule = new OnCallScheduleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "secondary-oncall",
RotationType = RotationType.Weekly,
Participants = """["charlie@example.com", "diana@example.com"]""",
Timezone = "UTC"
};
await _onCallRepository.CreateAsync(primarySchedule);
await _onCallRepository.CreateAsync(secondarySchedule);
// Act
var primary = await _onCallRepository.GetByNameAsync(_tenantId, "primary-oncall");
var secondary = await _onCallRepository.GetByNameAsync(_tenantId, "secondary-oncall");
// Assert
primary.Should().NotBeNull();
primary!.Participants.Should().Contain("alice@example.com");
secondary.Should().NotBeNull();
secondary!.Participants.Should().Contain("charlie@example.com");
}
[Fact]
public async Task Escalation_MultipleActiveStates_AllTracked()
{
// Arrange
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "multi-active-policy",
Enabled = true
};
await _policyRepository.CreateAsync(policy);
// Create multiple active escalations
var states = new List<EscalationStateEntity>();
for (int i = 0; i < 5; i++)
{
var state = new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
CorrelationId = $"incident-{i}-{Guid.NewGuid():N}",
CurrentStep = 1,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-i)
};
await _stateRepository.CreateAsync(state);
states.Add(state);
}
// Act
var active = await _stateRepository.GetActiveAsync(limit: 100);
// Assert
foreach (var state in states)
{
active.Should().Contain(s => s.Id == state.Id);
}
}
[Fact]
public async Task Escalation_PolicyDisabled_NotUsed()
{
// Arrange
var enabledPolicy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "enabled-policy",
Enabled = true
};
var disabledPolicy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "disabled-policy",
Enabled = false
};
await _policyRepository.CreateAsync(enabledPolicy);
await _policyRepository.CreateAsync(disabledPolicy);
// Act
var allPolicies = await _policyRepository.ListAsync(_tenantId);
var enabledOnly = allPolicies.Where(p => p.Enabled).ToList();
// Assert
allPolicies.Should().HaveCount(2);
enabledOnly.Should().ContainSingle(p => p.Id == enabledPolicy.Id);
}
[Fact]
public async Task Escalation_IncidentLinking_TracksAssociation()
{
// Arrange
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "incident-linked-policy",
Enabled = true
};
await _policyRepository.CreateAsync(policy);
var incident = new IncidentEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Title = "Security Breach",
Severity = "critical",
Status = IncidentStatus.Open,
CorrelationId = Guid.NewGuid().ToString()
};
await _incidentRepository.CreateAsync(incident);
var state = new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
IncidentId = incident.Id,
CorrelationId = incident.CorrelationId,
CurrentStep = 1,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow
};
await _stateRepository.CreateAsync(state);
// Act
var fetched = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.IncidentId.Should().Be(incident.Id);
}
[Fact]
public async Task Escalation_RepeatIteration_TracksRepeats()
{
// Arrange
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "repeating-policy",
Enabled = true,
RepeatCount = 3
};
await _policyRepository.CreateAsync(policy);
// Act - Create state at repeat iteration 2
var state = new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
CorrelationId = Guid.NewGuid().ToString(),
CurrentStep = 1,
RepeatIteration = 2,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow
};
await _stateRepository.CreateAsync(state);
// Assert
var fetched = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
fetched!.RepeatIteration.Should().Be(2);
}
[Fact]
public async Task Escalation_DeterministicOrdering_ConsistentResults()
{
// Arrange
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "determinism-policy",
Enabled = true
};
await _policyRepository.CreateAsync(policy);
// Create multiple active escalations
for (int i = 0; i < 10; i++)
{
await _stateRepository.CreateAsync(new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
CorrelationId = $"det-{i}-{Guid.NewGuid():N}",
CurrentStep = 1,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow.AddSeconds(i)
});
}
// Act
var results1 = await _stateRepository.GetActiveAsync(limit: 100);
var results2 = await _stateRepository.GetActiveAsync(limit: 100);
var results3 = await _stateRepository.GetActiveAsync(limit: 100);
// Assert
var ids1 = results1.Select(s => s.Id).ToList();
var ids2 = results2.Select(s => s.Id).ToList();
var ids3 = results3.Select(s => s.Id).ToList();
ids1.Should().Equal(ids2);
ids2.Should().Equal(ids3);
}
[Fact]
public async Task Escalation_Metadata_PreservedThroughLifecycle()
{
// Arrange
var policy = new EscalationPolicyEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "metadata-policy",
Enabled = true,
Metadata = """{"severity_levels": ["low", "medium", "high", "critical"]}"""
};
await _policyRepository.CreateAsync(policy);
var state = new EscalationStateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
PolicyId = policy.Id,
CorrelationId = Guid.NewGuid().ToString(),
CurrentStep = 1,
Status = EscalationStatus.Active,
StartedAt = DateTimeOffset.UtcNow,
Metadata = """{"original_severity": "critical", "source": "scanner"}"""
};
await _stateRepository.CreateAsync(state);
// Act
await _stateRepository.EscalateAsync(_tenantId, state.Id, 2, DateTimeOffset.UtcNow.AddMinutes(5));
await _stateRepository.AcknowledgeAsync(_tenantId, state.Id, "responder");
await _stateRepository.ResolveAsync(_tenantId, state.Id, "responder");
// Assert
var final = await _stateRepository.GetByIdAsync(_tenantId, state.Id);
final!.Metadata.Should().Contain("original_severity");
final.Metadata.Should().Contain("scanner");
}
}

View File

@@ -0,0 +1,405 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Notify.Storage.Postgres.Models;
using StellaOps.Notify.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Notify.Storage.Postgres.Tests;
/// <summary>
/// End-to-end tests for notification delivery flow (PG-T3.10.2).
/// Tests the complete lifecycle: channel creation → rule matching → template rendering → delivery tracking.
/// </summary>
[Collection(NotifyPostgresCollection.Name)]
public sealed class NotificationDeliveryFlowTests : IAsyncLifetime
{
private readonly NotifyPostgresFixture _fixture;
private readonly ChannelRepository _channelRepository;
private readonly RuleRepository _ruleRepository;
private readonly TemplateRepository _templateRepository;
private readonly DeliveryRepository _deliveryRepository;
private readonly NotifyAuditRepository _auditRepository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public NotificationDeliveryFlowTests(NotifyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new NotifyDataSource(Options.Create(options), NullLogger<NotifyDataSource>.Instance);
_channelRepository = new ChannelRepository(dataSource, NullLogger<ChannelRepository>.Instance);
_ruleRepository = new RuleRepository(dataSource, NullLogger<RuleRepository>.Instance);
_templateRepository = new TemplateRepository(dataSource, NullLogger<TemplateRepository>.Instance);
_deliveryRepository = new DeliveryRepository(dataSource, NullLogger<DeliveryRepository>.Instance);
_auditRepository = new NotifyAuditRepository(dataSource, NullLogger<NotifyAuditRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task DeliveryFlow_CompleteLifecycle_PendingToDelivered()
{
// Arrange - Create channel
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "email-notifications",
ChannelType = ChannelType.Email,
Config = """{"smtp_host": "smtp.example.com", "from": "noreply@example.com"}""",
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Create template
var template = new TemplateEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "scan-complete",
ChannelType = ChannelType.Email,
SubjectTemplate = "Scan Complete: {{scan_name}}",
BodyTemplate = "Your scan {{scan_name}} has completed with {{finding_count}} findings.",
Locale = "en"
};
await _templateRepository.CreateAsync(template);
// Create rule
var rule = new RuleEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "scan-completed-rule",
ChannelIds = [channel.Id],
TemplateId = template.Id,
EventTypes = ["scan.completed"],
Filter = """{"severity": ["high", "critical"]}""",
Enabled = true,
Priority = 100
};
await _ruleRepository.CreateAsync(rule);
// Act - Create delivery
var delivery = new DeliveryEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
RuleId = rule.Id,
TemplateId = template.Id,
Recipient = "security@example.com",
EventType = "scan.completed",
EventPayload = "{\"scan_name\": \"weekly-scan\", \"finding_count\": 42}",
Subject = "Scan Complete: weekly-scan",
Body = "Your scan weekly-scan has completed with 42 findings.",
CorrelationId = Guid.NewGuid().ToString(),
Status = DeliveryStatus.Pending
};
await _deliveryRepository.CreateAsync(delivery);
// Verify pending
var pending = await _deliveryRepository.GetPendingAsync(_tenantId);
pending.Should().ContainSingle(d => d.Id == delivery.Id);
// Progress through lifecycle: Pending → Queued
await _deliveryRepository.MarkQueuedAsync(_tenantId, delivery.Id);
var queued = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id);
queued!.Status.Should().Be(DeliveryStatus.Queued);
queued.QueuedAt.Should().NotBeNull();
// Queued → Sent
await _deliveryRepository.MarkSentAsync(_tenantId, delivery.Id, "smtp-msg-12345");
var sent = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id);
sent!.Status.Should().Be(DeliveryStatus.Sent);
sent.ExternalId.Should().Be("smtp-msg-12345");
sent.SentAt.Should().NotBeNull();
// Sent → Delivered
await _deliveryRepository.MarkDeliveredAsync(_tenantId, delivery.Id);
var delivered = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id);
delivered!.Status.Should().Be(DeliveryStatus.Delivered);
delivered.DeliveredAt.Should().NotBeNull();
// Verify no longer in pending queue
var finalPending = await _deliveryRepository.GetPendingAsync(_tenantId);
finalPending.Should().NotContain(d => d.Id == delivery.Id);
}
[Fact]
public async Task DeliveryFlow_FailureAndRetry_TracksErrorState()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "slack-channel",
ChannelType = ChannelType.Slack,
Config = """{"webhook_url": "https://hooks.slack.com/services/xxx"}""",
Enabled = true
};
await _channelRepository.CreateAsync(channel);
var delivery = new DeliveryEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "#security-alerts",
EventType = "vulnerability.detected",
Status = DeliveryStatus.Pending
};
await _deliveryRepository.CreateAsync(delivery);
// Act - Mark as failed with retry
await _deliveryRepository.MarkFailedAsync(_tenantId, delivery.Id, "Connection refused", TimeSpan.FromMinutes(5));
// Assert
var failed = await _deliveryRepository.GetByIdAsync(_tenantId, delivery.Id);
failed!.Status.Should().Be(DeliveryStatus.Failed);
failed.ErrorMessage.Should().Be("Connection refused");
failed.FailedAt.Should().NotBeNull();
failed.NextRetryAt.Should().NotBeNull();
failed.Attempt.Should().BeGreaterThanOrEqualTo(1);
}
[Fact]
public async Task DeliveryFlow_MultipleChannels_IndependentDeliveries()
{
// Arrange - Create two channels
var emailChannel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "email",
ChannelType = ChannelType.Email,
Enabled = true
};
var slackChannel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "slack",
ChannelType = ChannelType.Slack,
Enabled = true
};
await _channelRepository.CreateAsync(emailChannel);
await _channelRepository.CreateAsync(slackChannel);
var correlationId = Guid.NewGuid().ToString();
// Create deliveries for both channels with same correlation
var emailDelivery = new DeliveryEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = emailChannel.Id,
Recipient = "user@example.com",
EventType = "alert",
CorrelationId = correlationId,
Status = DeliveryStatus.Pending
};
var slackDelivery = new DeliveryEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = slackChannel.Id,
Recipient = "#alerts",
EventType = "alert",
CorrelationId = correlationId,
Status = DeliveryStatus.Pending
};
await _deliveryRepository.CreateAsync(emailDelivery);
await _deliveryRepository.CreateAsync(slackDelivery);
// Act - Email succeeds, Slack fails
await _deliveryRepository.MarkSentAsync(_tenantId, emailDelivery.Id);
await _deliveryRepository.MarkDeliveredAsync(_tenantId, emailDelivery.Id);
await _deliveryRepository.MarkFailedAsync(_tenantId, slackDelivery.Id, "Rate limited");
// Assert - Both tracked via correlation
var correlated = await _deliveryRepository.GetByCorrelationIdAsync(_tenantId, correlationId);
correlated.Should().HaveCount(2);
var email = correlated.First(d => d.ChannelId == emailChannel.Id);
var slack = correlated.First(d => d.ChannelId == slackChannel.Id);
email.Status.Should().Be(DeliveryStatus.Delivered);
slack.Status.Should().Be(DeliveryStatus.Failed);
}
[Fact]
public async Task DeliveryFlow_StatsAccumulation_CorrectAggregates()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "stats-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Create multiple deliveries in different states
// Note: DeliveryStats tracks Pending, Sent, Delivered, Failed, Bounced (no Queued)
var deliveries = new[]
{
(DeliveryStatus.Pending, (string?)null),
(DeliveryStatus.Pending, (string?)null),
(DeliveryStatus.Pending, (string?)null),
(DeliveryStatus.Sent, (string?)null),
(DeliveryStatus.Sent, (string?)null),
(DeliveryStatus.Sent, (string?)null),
(DeliveryStatus.Delivered, (string?)null),
(DeliveryStatus.Delivered, (string?)null),
(DeliveryStatus.Failed, "Error 1"),
(DeliveryStatus.Failed, "Error 2")
};
foreach (var (status, error) in deliveries)
{
var d = new DeliveryEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = "user@example.com",
EventType = "test",
Status = status,
ErrorMessage = error
};
await _deliveryRepository.CreateAsync(d);
}
// Act
var from = DateTimeOffset.UtcNow.AddHours(-1);
var to = DateTimeOffset.UtcNow.AddHours(1);
var stats = await _deliveryRepository.GetStatsAsync(_tenantId, from, to);
// Assert
stats.Total.Should().Be(10);
stats.Pending.Should().Be(3);
stats.Sent.Should().Be(3);
stats.Delivered.Should().Be(2);
stats.Failed.Should().Be(2);
}
[Fact]
public async Task DeliveryFlow_DeterministicOrdering_ConsistentResults()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "determinism-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Create multiple pending deliveries
for (int i = 0; i < 10; i++)
{
await _deliveryRepository.CreateAsync(new DeliveryEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
ChannelId = channel.Id,
Recipient = $"user{i}@example.com",
EventType = "test",
Status = DeliveryStatus.Pending
});
}
// Act - Query multiple times
var results1 = await _deliveryRepository.GetPendingAsync(_tenantId);
var results2 = await _deliveryRepository.GetPendingAsync(_tenantId);
var results3 = await _deliveryRepository.GetPendingAsync(_tenantId);
// Assert - Order should be deterministic
var ids1 = results1.Select(d => d.Id).ToList();
var ids2 = results2.Select(d => d.Id).ToList();
var ids3 = results3.Select(d => d.Id).ToList();
ids1.Should().Equal(ids2);
ids2.Should().Equal(ids3);
}
[Fact]
public async Task DeliveryFlow_AuditTrail_RecordsActions()
{
// Arrange
var channel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "audited-channel",
ChannelType = ChannelType.Email,
Enabled = true
};
await _channelRepository.CreateAsync(channel);
// Act - Record audit events
await _auditRepository.CreateAsync(new NotifyAuditEntity
{
TenantId = _tenantId,
Action = "channel.created",
ResourceType = "channel",
ResourceId = channel.Id.ToString(),
UserId = null,
Details = "{\"name\": \"audited-channel\"}"
});
await _auditRepository.CreateAsync(new NotifyAuditEntity
{
TenantId = _tenantId,
Action = "delivery.sent",
ResourceType = "delivery",
ResourceId = Guid.NewGuid().ToString(),
Details = "{\"recipient\": \"user@example.com\"}"
});
// Assert
var audits = await _auditRepository.GetByResourceAsync(_tenantId, "channel", channel.Id.ToString());
audits.Should().ContainSingle();
audits[0].Action.Should().Be("channel.created");
}
[Fact]
public async Task DeliveryFlow_DisabledChannel_NotQueried()
{
// Arrange
var enabledChannel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "enabled",
ChannelType = ChannelType.Email,
Enabled = true
};
var disabledChannel = new ChannelEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "disabled",
ChannelType = ChannelType.Email,
Enabled = false
};
await _channelRepository.CreateAsync(enabledChannel);
await _channelRepository.CreateAsync(disabledChannel);
// Act - Get all channels filtered by enabled=true
var enabled = await _channelRepository.GetAllAsync(_tenantId, enabled: true);
// Assert
enabled.Should().ContainSingle(c => c.Id == enabledChannel.Id);
enabled.Should().NotContain(c => c.Id == disabledChannel.Id);
}
}

View File

@@ -9,7 +9,6 @@
<ItemGroup>
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
<PackageReference Include="System.Text.Json" Version="10.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,335 @@
using System.Text.Json;
namespace StellaOps.Policy.Storage.Postgres.Migration;
/// <summary>
/// Converts MongoDB policy documents (as JSON) to migration data transfer objects.
/// Task reference: PG-T4.9
/// </summary>
/// <remarks>
/// This converter handles the transformation of MongoDB document JSON exports
/// into DTOs suitable for PostgreSQL import. The caller is responsible for
/// exporting MongoDB documents as JSON before passing them to this converter.
/// </remarks>
public static class MongoDocumentConverter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
/// <summary>
/// Converts a MongoDB PolicyDocument (as JSON) to PackMigrationData.
/// </summary>
/// <param name="json">The JSON representation of the MongoDB document.</param>
/// <returns>Migration data transfer object.</returns>
public static PackMigrationData ConvertPackFromJson(string json)
{
ArgumentException.ThrowIfNullOrEmpty(json);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
return new PackMigrationData
{
SourceId = GetString(root, "_id") ?? GetString(root, "id") ?? throw new InvalidOperationException("Missing _id"),
TenantId = GetString(root, "tenantId") ?? "",
Name = GetString(root, "_id") ?? GetString(root, "id") ?? throw new InvalidOperationException("Missing name"),
DisplayName = GetString(root, "displayName"),
Description = GetString(root, "description"),
ActiveVersion = GetNullableInt(root, "activeVersion"),
LatestVersion = GetInt(root, "latestVersion", 0),
IsBuiltin = GetBool(root, "isBuiltin", false),
Metadata = ExtractMetadata(root),
CreatedAt = GetDateTimeOffset(root, "createdAt", DateTimeOffset.UtcNow),
UpdatedAt = GetDateTimeOffset(root, "updatedAt", DateTimeOffset.UtcNow),
CreatedBy = GetString(root, "createdBy")
};
}
/// <summary>
/// Converts a MongoDB PolicyRevisionDocument (as JSON) to PackVersionMigrationData.
/// </summary>
/// <param name="json">The JSON representation of the MongoDB document.</param>
/// <returns>Migration data transfer object.</returns>
public static PackVersionMigrationData ConvertVersionFromJson(string json)
{
ArgumentException.ThrowIfNullOrEmpty(json);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var status = GetString(root, "status") ?? "Draft";
var isPublished = status == "Active" || status == "Approved";
return new PackVersionMigrationData
{
SourceId = GetString(root, "_id") ?? GetString(root, "id") ?? throw new InvalidOperationException("Missing _id"),
Version = GetInt(root, "version", 1),
Description = GetString(root, "description"),
RulesHash = GetString(root, "bundleDigest"),
IsPublished = isPublished,
PublishedAt = isPublished ? GetNullableDateTimeOffset(root, "activatedAt") : null,
PublishedBy = GetString(root, "publishedBy"),
CreatedAt = GetDateTimeOffset(root, "createdAt", DateTimeOffset.UtcNow),
CreatedBy = GetString(root, "createdBy")
};
}
/// <summary>
/// Creates a simple rule migration entry from raw Rego content.
/// </summary>
/// <param name="name">Rule name.</param>
/// <param name="content">Rego content.</param>
/// <param name="severity">Optional severity.</param>
/// <returns>Rule migration data.</returns>
public static RuleMigrationData CreateRuleFromContent(
string name,
string content,
string? severity = null)
{
return new RuleMigrationData
{
Name = name,
Content = content,
RuleType = "rego",
Severity = severity ?? "medium",
CreatedAt = DateTimeOffset.UtcNow
};
}
/// <summary>
/// Parses multiple pack documents from a JSON array.
/// </summary>
/// <param name="jsonArray">JSON array of pack documents.</param>
/// <returns>List of migration data objects.</returns>
public static IReadOnlyList<PackMigrationData> ConvertPacksFromJsonArray(string jsonArray)
{
ArgumentException.ThrowIfNullOrEmpty(jsonArray);
using var doc = JsonDocument.Parse(jsonArray);
var results = new List<PackMigrationData>();
if (doc.RootElement.ValueKind != JsonValueKind.Array)
{
throw new ArgumentException("Expected a JSON array", nameof(jsonArray));
}
foreach (var element in doc.RootElement.EnumerateArray())
{
results.Add(ConvertPackElement(element));
}
return results;
}
/// <summary>
/// Parses multiple version documents from a JSON array.
/// </summary>
/// <param name="jsonArray">JSON array of version documents.</param>
/// <returns>List of migration data objects.</returns>
public static IReadOnlyList<PackVersionMigrationData> ConvertVersionsFromJsonArray(string jsonArray)
{
ArgumentException.ThrowIfNullOrEmpty(jsonArray);
using var doc = JsonDocument.Parse(jsonArray);
var results = new List<PackVersionMigrationData>();
if (doc.RootElement.ValueKind != JsonValueKind.Array)
{
throw new ArgumentException("Expected a JSON array", nameof(jsonArray));
}
foreach (var element in doc.RootElement.EnumerateArray())
{
results.Add(ConvertVersionElement(element));
}
return results;
}
private static PackMigrationData ConvertPackElement(JsonElement root)
{
return new PackMigrationData
{
SourceId = GetString(root, "_id") ?? GetString(root, "id") ?? throw new InvalidOperationException("Missing _id"),
TenantId = GetString(root, "tenantId") ?? "",
Name = GetString(root, "_id") ?? GetString(root, "id") ?? throw new InvalidOperationException("Missing name"),
DisplayName = GetString(root, "displayName"),
Description = GetString(root, "description"),
ActiveVersion = GetNullableInt(root, "activeVersion"),
LatestVersion = GetInt(root, "latestVersion", 0),
IsBuiltin = GetBool(root, "isBuiltin", false),
Metadata = ExtractMetadata(root),
CreatedAt = GetDateTimeOffset(root, "createdAt", DateTimeOffset.UtcNow),
UpdatedAt = GetDateTimeOffset(root, "updatedAt", DateTimeOffset.UtcNow),
CreatedBy = GetString(root, "createdBy")
};
}
private static PackVersionMigrationData ConvertVersionElement(JsonElement root)
{
var status = GetString(root, "status") ?? "Draft";
var isPublished = status == "Active" || status == "Approved";
return new PackVersionMigrationData
{
SourceId = GetString(root, "_id") ?? GetString(root, "id") ?? throw new InvalidOperationException("Missing _id"),
Version = GetInt(root, "version", 1),
Description = GetString(root, "description"),
RulesHash = GetString(root, "bundleDigest"),
IsPublished = isPublished,
PublishedAt = isPublished ? GetNullableDateTimeOffset(root, "activatedAt") : null,
PublishedBy = GetString(root, "publishedBy"),
CreatedAt = GetDateTimeOffset(root, "createdAt", DateTimeOffset.UtcNow),
CreatedBy = GetString(root, "createdBy")
};
}
private static string? GetString(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var prop))
{
return null;
}
return prop.ValueKind == JsonValueKind.String ? prop.GetString() : null;
}
private static int GetInt(JsonElement element, string propertyName, int defaultValue)
{
if (!element.TryGetProperty(propertyName, out var prop))
{
return defaultValue;
}
return prop.ValueKind == JsonValueKind.Number ? prop.GetInt32() : defaultValue;
}
private static int? GetNullableInt(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var prop))
{
return null;
}
if (prop.ValueKind == JsonValueKind.Null)
{
return null;
}
return prop.ValueKind == JsonValueKind.Number ? prop.GetInt32() : null;
}
private static bool GetBool(JsonElement element, string propertyName, bool defaultValue)
{
if (!element.TryGetProperty(propertyName, out var prop))
{
return defaultValue;
}
if (prop.ValueKind == JsonValueKind.True)
{
return true;
}
if (prop.ValueKind == JsonValueKind.False)
{
return false;
}
return defaultValue;
}
private static DateTimeOffset GetDateTimeOffset(JsonElement element, string propertyName, DateTimeOffset defaultValue)
{
if (!element.TryGetProperty(propertyName, out var prop))
{
return defaultValue;
}
if (prop.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(prop.GetString(), out var result))
{
return result;
}
// Handle MongoDB extended JSON date format {"$date": ...}
if (prop.ValueKind == JsonValueKind.Object && prop.TryGetProperty("$date", out var dateProp))
{
if (dateProp.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(dateProp.GetString(), out var dateResult))
{
return dateResult;
}
if (dateProp.ValueKind == JsonValueKind.Number)
{
return DateTimeOffset.FromUnixTimeMilliseconds(dateProp.GetInt64());
}
}
return defaultValue;
}
private static DateTimeOffset? GetNullableDateTimeOffset(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var prop))
{
return null;
}
if (prop.ValueKind == JsonValueKind.Null)
{
return null;
}
if (prop.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(prop.GetString(), out var result))
{
return result;
}
// Handle MongoDB extended JSON date format
if (prop.ValueKind == JsonValueKind.Object && prop.TryGetProperty("$date", out var dateProp))
{
if (dateProp.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(dateProp.GetString(), out var dateResult))
{
return dateResult;
}
if (dateProp.ValueKind == JsonValueKind.Number)
{
return DateTimeOffset.FromUnixTimeMilliseconds(dateProp.GetInt64());
}
}
return null;
}
private static string ExtractMetadata(JsonElement element)
{
var metadata = new Dictionary<string, object>();
if (element.TryGetProperty("tags", out var tagsProp) && tagsProp.ValueKind == JsonValueKind.Array)
{
var tags = new List<string>();
foreach (var tag in tagsProp.EnumerateArray())
{
if (tag.ValueKind == JsonValueKind.String)
{
tags.Add(tag.GetString()!);
}
}
if (tags.Count > 0)
{
metadata["tags"] = tags;
}
}
if (metadata.Count == 0)
{
return "{}";
}
return JsonSerializer.Serialize(metadata);
}
}

View File

@@ -0,0 +1,467 @@
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Storage.Postgres.Models;
using StellaOps.Policy.Storage.Postgres.Repositories;
namespace StellaOps.Policy.Storage.Postgres.Migration;
/// <summary>
/// Handles migration of policy data from MongoDB to PostgreSQL.
/// Task references: PG-T4.9, PG-T4.10, PG-T4.11
/// </summary>
/// <remarks>
/// This migrator converts policy packs and their versions from MongoDB documents
/// to PostgreSQL entities while preserving version history and active version settings.
/// </remarks>
public sealed class PolicyMigrator
{
private readonly IPackRepository _packRepository;
private readonly IPackVersionRepository _versionRepository;
private readonly IRuleRepository _ruleRepository;
private readonly ILogger<PolicyMigrator> _logger;
public PolicyMigrator(
IPackRepository packRepository,
IPackVersionRepository versionRepository,
IRuleRepository ruleRepository,
ILogger<PolicyMigrator> logger)
{
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
_versionRepository = versionRepository ?? throw new ArgumentNullException(nameof(versionRepository));
_ruleRepository = ruleRepository ?? throw new ArgumentNullException(nameof(ruleRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Imports a policy pack and its versions to PostgreSQL.
/// </summary>
/// <param name="pack">The pack data to import.</param>
/// <param name="versions">The pack versions to import.</param>
/// <param name="rules">Rules associated with each version, keyed by version number.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Migration result with success status and any errors.</returns>
public async Task<PackMigrationResult> ImportPackAsync(
PackMigrationData pack,
IReadOnlyList<PackVersionMigrationData> versions,
IReadOnlyDictionary<int, IReadOnlyList<RuleMigrationData>>? rules,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(pack);
ArgumentNullException.ThrowIfNull(versions);
var result = new PackMigrationResult
{
PackId = pack.SourceId,
TenantId = pack.TenantId,
PackName = pack.Name
};
try
{
_logger.LogInformation(
"Starting migration of pack {PackId} ({PackName}) for tenant {TenantId}",
pack.SourceId, pack.Name, pack.TenantId);
// Check if pack already exists
var existingPack = await _packRepository.GetByNameAsync(pack.TenantId, pack.Name, cancellationToken);
if (existingPack is not null)
{
_logger.LogWarning(
"Pack {PackName} already exists in PostgreSQL for tenant {TenantId}, skipping",
pack.Name, pack.TenantId);
result.Skipped = true;
result.SkipReason = "Pack already exists";
return result;
}
// Create pack entity
var packEntity = new PackEntity
{
Id = Guid.NewGuid(),
TenantId = pack.TenantId,
Name = pack.Name,
DisplayName = pack.DisplayName,
Description = pack.Description,
ActiveVersion = pack.ActiveVersion,
IsBuiltin = pack.IsBuiltin,
IsDeprecated = false,
Metadata = pack.Metadata ?? "{}",
CreatedAt = pack.CreatedAt,
UpdatedAt = pack.UpdatedAt,
CreatedBy = pack.CreatedBy
};
var createdPack = await _packRepository.CreateAsync(packEntity, cancellationToken);
result.PostgresPackId = createdPack.Id;
_logger.LogDebug("Created pack {PackId} in PostgreSQL", createdPack.Id);
// Import versions
foreach (var version in versions.OrderBy(v => v.Version))
{
var versionResult = await ImportVersionAsync(
createdPack.Id,
version,
rules?.GetValueOrDefault(version.Version),
cancellationToken);
result.VersionResults.Add(versionResult);
if (!versionResult.Success)
{
_logger.LogWarning(
"Failed to import version {Version} for pack {PackName}: {Error}",
version.Version, pack.Name, versionResult.ErrorMessage);
}
}
result.Success = result.VersionResults.All(v => v.Success || v.Skipped);
result.VersionsImported = result.VersionResults.Count(v => v.Success);
_logger.LogInformation(
"Completed migration of pack {PackName}: {VersionsImported} versions imported",
pack.Name, result.VersionsImported);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to migrate pack {PackId} ({PackName})", pack.SourceId, pack.Name);
result.Success = false;
result.ErrorMessage = ex.Message;
}
return result;
}
private async Task<VersionMigrationResult> ImportVersionAsync(
Guid packId,
PackVersionMigrationData version,
IReadOnlyList<RuleMigrationData>? rules,
CancellationToken cancellationToken)
{
var result = new VersionMigrationResult
{
Version = version.Version
};
try
{
// Check if version already exists
var existingVersion = await _versionRepository.GetByPackAndVersionAsync(packId, version.Version, cancellationToken);
if (existingVersion is not null)
{
result.Skipped = true;
result.SkipReason = "Version already exists";
return result;
}
var versionEntity = new PackVersionEntity
{
Id = Guid.NewGuid(),
PackId = packId,
Version = version.Version,
Description = version.Description,
RulesHash = version.RulesHash ?? ComputeRulesHash(rules),
IsPublished = version.IsPublished,
PublishedAt = version.PublishedAt,
PublishedBy = version.PublishedBy,
CreatedAt = version.CreatedAt,
CreatedBy = version.CreatedBy
};
var createdVersion = await _versionRepository.CreateAsync(versionEntity, cancellationToken);
result.PostgresVersionId = createdVersion.Id;
// Import rules if provided
if (rules is not null && rules.Count > 0)
{
foreach (var rule in rules)
{
var ruleEntity = new RuleEntity
{
Id = Guid.NewGuid(),
PackVersionId = createdVersion.Id,
Name = rule.Name,
Description = rule.Description,
Content = rule.Content,
RuleType = ParseRuleType(rule.RuleType),
ContentHash = rule.ContentHash ?? ComputeContentHash(rule.Content),
Severity = ParseSeverity(rule.Severity),
Category = rule.Category,
Tags = rule.Tags ?? [],
Metadata = rule.Metadata ?? "{}",
CreatedAt = rule.CreatedAt ?? DateTimeOffset.UtcNow
};
await _ruleRepository.CreateAsync(ruleEntity, cancellationToken);
result.RulesImported++;
}
}
result.Success = true;
}
catch (Exception ex)
{
result.Success = false;
result.ErrorMessage = ex.Message;
}
return result;
}
/// <summary>
/// Verifies that migrated data matches between MongoDB and PostgreSQL.
/// </summary>
/// <param name="tenantId">Tenant to verify.</param>
/// <param name="expectedPacks">Expected pack count from MongoDB.</param>
/// <param name="expectedVersions">Expected version counts per pack.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result.</returns>
public async Task<MigrationVerificationResult> VerifyMigrationAsync(
string tenantId,
int expectedPacks,
IReadOnlyDictionary<string, int>? expectedVersions,
CancellationToken cancellationToken = default)
{
var result = new MigrationVerificationResult
{
TenantId = tenantId
};
try
{
var packs = await _packRepository.GetAllAsync(
tenantId,
includeBuiltin: true,
includeDeprecated: true,
limit: 1000,
cancellationToken: cancellationToken);
result.ActualPackCount = packs.Count;
result.ExpectedPackCount = expectedPacks;
if (expectedVersions is not null)
{
foreach (var pack in packs)
{
var versions = await _versionRepository.GetByPackIdAsync(pack.Id, cancellationToken: cancellationToken);
result.ActualVersionCounts[pack.Name] = versions.Count;
if (expectedVersions.TryGetValue(pack.Name, out var expected))
{
result.ExpectedVersionCounts[pack.Name] = expected;
if (versions.Count != expected)
{
result.Discrepancies.Add(
$"Pack '{pack.Name}': expected {expected} versions, found {versions.Count}");
}
}
}
}
result.Success = result.ActualPackCount == expectedPacks && result.Discrepancies.Count == 0;
}
catch (Exception ex)
{
result.Success = false;
result.ErrorMessage = ex.Message;
}
return result;
}
private static string ComputeRulesHash(IReadOnlyList<RuleMigrationData>? rules)
{
if (rules is null || rules.Count == 0)
{
return "empty";
}
var combined = string.Join("|", rules.OrderBy(r => r.Name).Select(r => r.ContentHash ?? r.Content));
return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(combined))).ToLowerInvariant();
}
private static string ComputeContentHash(string content)
{
return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(content))).ToLowerInvariant();
}
private static RuleType ParseRuleType(string? ruleType)
{
return ruleType?.ToLowerInvariant() switch
{
"rego" => RuleType.Rego,
"json" => RuleType.Json,
"yaml" => RuleType.Yaml,
_ => RuleType.Rego
};
}
private static RuleSeverity ParseSeverity(string? severity)
{
return severity?.ToLowerInvariant() switch
{
"critical" => RuleSeverity.Critical,
"high" => RuleSeverity.High,
"medium" => RuleSeverity.Medium,
"low" => RuleSeverity.Low,
"info" => RuleSeverity.Info,
_ => RuleSeverity.Medium
};
}
}
/// <summary>
/// Data transfer object for pack migration.
/// </summary>
public sealed class PackMigrationData
{
/// <summary>Source system identifier (MongoDB _id).</summary>
public required string SourceId { get; init; }
/// <summary>Tenant identifier.</summary>
public required string TenantId { get; init; }
/// <summary>Pack name.</summary>
public required string Name { get; init; }
/// <summary>Display name.</summary>
public string? DisplayName { get; init; }
/// <summary>Description.</summary>
public string? Description { get; init; }
/// <summary>Currently active version.</summary>
public int? ActiveVersion { get; init; }
/// <summary>Latest version number.</summary>
public int LatestVersion { get; init; }
/// <summary>Whether this is a built-in pack.</summary>
public bool IsBuiltin { get; init; }
/// <summary>Metadata JSON.</summary>
public string? Metadata { get; init; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>Last update timestamp.</summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>Creator.</summary>
public string? CreatedBy { get; init; }
}
/// <summary>
/// Data transfer object for pack version migration.
/// </summary>
public sealed class PackVersionMigrationData
{
/// <summary>Source system identifier.</summary>
public required string SourceId { get; init; }
/// <summary>Version number.</summary>
public required int Version { get; init; }
/// <summary>Description.</summary>
public string? Description { get; init; }
/// <summary>Hash of rules in this version.</summary>
public string? RulesHash { get; init; }
/// <summary>Whether published.</summary>
public bool IsPublished { get; init; }
/// <summary>Publish timestamp.</summary>
public DateTimeOffset? PublishedAt { get; init; }
/// <summary>Publisher.</summary>
public string? PublishedBy { get; init; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>Creator.</summary>
public string? CreatedBy { get; init; }
}
/// <summary>
/// Data transfer object for rule migration.
/// </summary>
public sealed class RuleMigrationData
{
/// <summary>Rule name.</summary>
public required string Name { get; init; }
/// <summary>Description.</summary>
public string? Description { get; init; }
/// <summary>Rule content (Rego, JSON, or YAML).</summary>
public required string Content { get; init; }
/// <summary>Rule type (rego, json, yaml).</summary>
public string? RuleType { get; init; }
/// <summary>Content hash.</summary>
public string? ContentHash { get; init; }
/// <summary>Severity level.</summary>
public string? Severity { get; init; }
/// <summary>Category.</summary>
public string? Category { get; init; }
/// <summary>Tags.</summary>
public string[]? Tags { get; init; }
/// <summary>Metadata JSON.</summary>
public string? Metadata { get; init; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset? CreatedAt { get; init; }
}
/// <summary>
/// Result of pack migration operation.
/// </summary>
public sealed class PackMigrationResult
{
public required string PackId { get; init; }
public required string TenantId { get; init; }
public required string PackName { get; init; }
public Guid? PostgresPackId { get; set; }
public bool Success { get; set; }
public bool Skipped { get; set; }
public string? SkipReason { get; set; }
public string? ErrorMessage { get; set; }
public int VersionsImported { get; set; }
public List<VersionMigrationResult> VersionResults { get; } = [];
}
/// <summary>
/// Result of version migration operation.
/// </summary>
public sealed class VersionMigrationResult
{
public required int Version { get; init; }
public Guid? PostgresVersionId { get; set; }
public bool Success { get; set; }
public bool Skipped { get; set; }
public string? SkipReason { get; set; }
public string? ErrorMessage { get; set; }
public int RulesImported { get; set; }
}
/// <summary>
/// Result of migration verification.
/// </summary>
public sealed class MigrationVerificationResult
{
public required string TenantId { get; init; }
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public int ExpectedPackCount { get; set; }
public int ActualPackCount { get; set; }
public Dictionary<string, int> ExpectedVersionCounts { get; } = [];
public Dictionary<string, int> ActualVersionCounts { get; } = [];
public List<string> Discrepancies { get; } = [];
}

View File

@@ -0,0 +1,281 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Storage.Postgres.Models;
using StellaOps.Policy.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Policy.Storage.Postgres.Tests;
/// <summary>
/// Tests for pack versioning workflow scenarios (PG-T4.8.2).
/// Validates the complete lifecycle of pack versioning including:
/// - Creating pack versions
/// - Activating/deactivating versions
/// - Rolling back to previous versions
/// - Version history preservation
/// </summary>
[Collection(PolicyPostgresCollection.Name)]
public sealed class PackVersioningWorkflowTests : IAsyncLifetime
{
private readonly PolicyPostgresFixture _fixture;
private readonly PackRepository _packRepository;
private readonly RuleRepository _ruleRepository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public PackVersioningWorkflowTests(PolicyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new PolicyDataSource(Options.Create(options), NullLogger<PolicyDataSource>.Instance);
_packRepository = new PackRepository(dataSource, NullLogger<PackRepository>.Instance);
_ruleRepository = new RuleRepository(dataSource, NullLogger<RuleRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task VersionWorkflow_CreateUpdateActivate_MaintainsVersionIntegrity()
{
// Arrange - Create initial pack
var pack = new PackEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "versioned-pack",
DisplayName = "Versioned Policy Pack",
Description = "Pack for version testing",
ActiveVersion = 1,
IsBuiltin = false
};
await _packRepository.CreateAsync(pack);
// Act - Update to version 2
await _packRepository.SetActiveVersionAsync(_tenantId, pack.Id, 2);
var afterV2 = await _packRepository.GetByIdAsync(_tenantId, pack.Id);
// Assert
afterV2.Should().NotBeNull();
afterV2!.ActiveVersion.Should().Be(2);
// Act - Update to version 3
await _packRepository.SetActiveVersionAsync(_tenantId, pack.Id, 3);
var afterV3 = await _packRepository.GetByIdAsync(_tenantId, pack.Id);
// Assert
afterV3.Should().NotBeNull();
afterV3!.ActiveVersion.Should().Be(3);
}
[Fact]
public async Task VersionWorkflow_RollbackVersion_RestoresPreviousVersion()
{
// Arrange - Create pack at version 3
var pack = new PackEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "rollback-pack",
ActiveVersion = 3,
IsBuiltin = false
};
await _packRepository.CreateAsync(pack);
// Act - Rollback to version 2
await _packRepository.SetActiveVersionAsync(_tenantId, pack.Id, 2);
var afterRollback = await _packRepository.GetByIdAsync(_tenantId, pack.Id);
// Assert
afterRollback.Should().NotBeNull();
afterRollback!.ActiveVersion.Should().Be(2);
}
[Fact]
public async Task VersionWorkflow_MultiplePacksDifferentVersions_Isolated()
{
// Arrange - Create multiple packs with different versions
var pack1 = new PackEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "pack-a",
ActiveVersion = 1
};
var pack2 = new PackEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "pack-b",
ActiveVersion = 5
};
await _packRepository.CreateAsync(pack1);
await _packRepository.CreateAsync(pack2);
// Act - Update pack1 only
await _packRepository.SetActiveVersionAsync(_tenantId, pack1.Id, 10);
// Assert - pack2 should be unaffected
var fetchedPack1 = await _packRepository.GetByIdAsync(_tenantId, pack1.Id);
var fetchedPack2 = await _packRepository.GetByIdAsync(_tenantId, pack2.Id);
fetchedPack1!.ActiveVersion.Should().Be(10);
fetchedPack2!.ActiveVersion.Should().Be(5);
}
[Fact]
public async Task VersionWorkflow_DeprecatedPackVersionStillReadable()
{
// Arrange - Create and deprecate pack
var pack = new PackEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "deprecated-version-pack",
ActiveVersion = 3,
IsDeprecated = false
};
await _packRepository.CreateAsync(pack);
// Act - Deprecate the pack
await _packRepository.DeprecateAsync(_tenantId, pack.Id);
var deprecated = await _packRepository.GetByIdAsync(_tenantId, pack.Id);
// Assert - Version should still be readable
deprecated.Should().NotBeNull();
deprecated!.IsDeprecated.Should().BeTrue();
deprecated.ActiveVersion.Should().Be(3);
}
[Fact]
public async Task VersionWorkflow_ConcurrentVersionUpdates_LastWriteWins()
{
// Arrange - Create pack
var pack = new PackEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "concurrent-version-pack",
ActiveVersion = 1
};
await _packRepository.CreateAsync(pack);
// Act - Simulate concurrent updates
var tasks = new[]
{
_packRepository.SetActiveVersionAsync(_tenantId, pack.Id, 2),
_packRepository.SetActiveVersionAsync(_tenantId, pack.Id, 3),
_packRepository.SetActiveVersionAsync(_tenantId, pack.Id, 4)
};
await Task.WhenAll(tasks);
// Assert - One of the versions should win
var final = await _packRepository.GetByIdAsync(_tenantId, pack.Id);
final.Should().NotBeNull();
final!.ActiveVersion.Should().BeOneOf(2, 3, 4);
}
[Fact]
public async Task VersionWorkflow_DeterministicOrdering_VersionsReturnConsistently()
{
// Arrange - Create multiple packs
var packs = Enumerable.Range(1, 5).Select(i => new PackEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = $"ordered-pack-{i}",
ActiveVersion = i
}).ToList();
foreach (var pack in packs)
{
await _packRepository.CreateAsync(pack);
}
// Act - Fetch multiple times
var results1 = await _packRepository.GetAllAsync(_tenantId);
var results2 = await _packRepository.GetAllAsync(_tenantId);
var results3 = await _packRepository.GetAllAsync(_tenantId);
// Assert - Order should be deterministic
var names1 = results1.Select(p => p.Name).ToList();
var names2 = results2.Select(p => p.Name).ToList();
var names3 = results3.Select(p => p.Name).ToList();
names1.Should().Equal(names2);
names2.Should().Equal(names3);
}
[Fact]
public async Task VersionWorkflow_UpdateTimestampProgresses_OnVersionChange()
{
// Arrange
var pack = new PackEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "timestamp-version-pack",
ActiveVersion = 1
};
await _packRepository.CreateAsync(pack);
var created = await _packRepository.GetByIdAsync(_tenantId, pack.Id);
var initialUpdatedAt = created!.UpdatedAt;
// Small delay to ensure timestamp difference
await Task.Delay(10);
// Act - Update version
await _packRepository.SetActiveVersionAsync(_tenantId, pack.Id, 2);
var updated = await _packRepository.GetByIdAsync(_tenantId, pack.Id);
// Assert - UpdatedAt should have progressed
updated!.UpdatedAt.Should().BeOnOrAfter(initialUpdatedAt);
}
[Fact]
public async Task VersionWorkflow_ZeroVersionAllowed_AsInitialState()
{
// Arrange - Create pack with version 0 (no active version)
var pack = new PackEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "zero-version-pack",
ActiveVersion = 0
};
// Act
await _packRepository.CreateAsync(pack);
var fetched = await _packRepository.GetByIdAsync(_tenantId, pack.Id);
// Assert
fetched.Should().NotBeNull();
fetched!.ActiveVersion.Should().Be(0);
}
[Fact]
public async Task VersionWorkflow_BuiltinPackVersioning_WorksLikeCustomPacks()
{
// Arrange - Create builtin pack
var builtinPack = new PackEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "builtin-versioned",
ActiveVersion = 1,
IsBuiltin = true
};
await _packRepository.CreateAsync(builtinPack);
// Act - Update version
await _packRepository.SetActiveVersionAsync(_tenantId, builtinPack.Id, 2);
var updated = await _packRepository.GetByIdAsync(_tenantId, builtinPack.Id);
// Assert
updated.Should().NotBeNull();
updated!.ActiveVersion.Should().Be(2);
updated.IsBuiltin.Should().BeTrue();
}
}

View File

@@ -0,0 +1,473 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Storage.Postgres.Models;
using StellaOps.Policy.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Policy.Storage.Postgres.Tests;
/// <summary>
/// Tests for risk profile version history scenarios (PG-T4.8.3).
/// Validates the complete lifecycle of risk profile versioning including:
/// - Creating multiple versions of the same profile
/// - Activating specific versions
/// - Retrieving version history
/// - Deactivating versions
/// - Deterministic ordering of version queries
/// </summary>
[Collection(PolicyPostgresCollection.Name)]
public sealed class RiskProfileVersionHistoryTests : IAsyncLifetime
{
private readonly PolicyPostgresFixture _fixture;
private readonly RiskProfileRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public RiskProfileVersionHistoryTests(PolicyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new PolicyDataSource(Options.Create(options), NullLogger<PolicyDataSource>.Instance);
_repository = new RiskProfileRepository(dataSource, NullLogger<RiskProfileRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task VersionHistory_CreateMultipleVersions_AllVersionsRetrievable()
{
// Arrange - Create profile with multiple versions
var profileName = "multi-version-profile";
for (int version = 1; version <= 5; version++)
{
var profile = new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = profileName,
DisplayName = $"Version {version}",
Version = version,
IsActive = version == 5, // Only latest is active
Thresholds = $"{{\"critical\": {9.0 - version * 0.1}}}",
ScoringWeights = "{\"vulnerability\": 1.0}"
};
await _repository.CreateAsync(profile);
}
// Act
var allVersions = await _repository.GetVersionsByNameAsync(_tenantId, profileName);
// Assert
allVersions.Should().HaveCount(5);
allVersions.Should().OnlyContain(p => p.Name == profileName);
allVersions.Select(p => p.Version).Should().BeEquivalentTo([1, 2, 3, 4, 5]);
}
[Fact]
public async Task VersionHistory_OnlyOneActivePerName_Enforced()
{
// Arrange - Create profile versions where only one should be active
var profileName = "single-active-profile";
var v1 = new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = profileName,
Version = 1,
IsActive = false
};
var v2 = new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = profileName,
Version = 2,
IsActive = true
};
await _repository.CreateAsync(v1);
await _repository.CreateAsync(v2);
// Act - Get active version
var active = await _repository.GetActiveByNameAsync(_tenantId, profileName);
// Assert
active.Should().NotBeNull();
active!.Version.Should().Be(2);
active.IsActive.Should().BeTrue();
}
[Fact]
public async Task VersionHistory_ActivateOlderVersion_DeactivatesNewer()
{
// Arrange - Create two versions, v2 active
var profileName = "activate-older-profile";
var v1 = new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = profileName,
Version = 1,
IsActive = false
};
var v2 = new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = profileName,
Version = 2,
IsActive = true
};
await _repository.CreateAsync(v1);
await _repository.CreateAsync(v2);
// Act - Activate v1
await _repository.ActivateAsync(_tenantId, v1.Id);
// Assert
var fetchedV1 = await _repository.GetByIdAsync(_tenantId, v1.Id);
fetchedV1!.IsActive.Should().BeTrue();
}
[Fact]
public async Task VersionHistory_CreateVersion_IncreasesVersionNumber()
{
// Arrange - Create initial profile
var profileName = "version-increment-profile";
var v1 = new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = profileName,
Version = 1,
IsActive = true,
Thresholds = "{\"critical\": 9.0}"
};
await _repository.CreateAsync(v1);
// Act - Create new version with updated thresholds
var newVersion = new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = profileName,
DisplayName = "New Version with Lower Threshold",
Version = 2,
IsActive = true,
Thresholds = "{\"critical\": 8.5}"
};
var created = await _repository.CreateVersionAsync(_tenantId, profileName, newVersion);
// Assert
created.Should().NotBeNull();
created.Version.Should().Be(2);
created.Thresholds.Should().Contain("8.5");
}
[Fact]
public async Task VersionHistory_GetVersionsByName_OrderedByVersion()
{
// Arrange - Create versions out of order
var profileName = "ordered-history-profile";
await _repository.CreateAsync(new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = profileName,
Version = 3,
IsActive = false
});
await _repository.CreateAsync(new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = profileName,
Version = 1,
IsActive = false
});
await _repository.CreateAsync(new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = profileName,
Version = 2,
IsActive = true
});
// Act
var versions = await _repository.GetVersionsByNameAsync(_tenantId, profileName);
// Assert - Should be ordered by version
versions.Should().HaveCount(3);
versions[0].Version.Should().Be(1);
versions[1].Version.Should().Be(2);
versions[2].Version.Should().Be(3);
}
[Fact]
public async Task VersionHistory_DeterministicOrdering_ConsistentResults()
{
// Arrange - Create multiple profiles with multiple versions
for (int profileNum = 1; profileNum <= 3; profileNum++)
{
for (int version = 1; version <= 3; version++)
{
await _repository.CreateAsync(new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = $"determinism-profile-{profileNum}",
Version = version,
IsActive = version == 3
});
}
}
// Act - Fetch multiple times
var results1 = await _repository.GetAllAsync(_tenantId);
var results2 = await _repository.GetAllAsync(_tenantId);
var results3 = await _repository.GetAllAsync(_tenantId);
// Assert - Order should be identical
var keys1 = results1.Select(p => $"{p.Name}-v{p.Version}").ToList();
var keys2 = results2.Select(p => $"{p.Name}-v{p.Version}").ToList();
var keys3 = results3.Select(p => $"{p.Name}-v{p.Version}").ToList();
keys1.Should().Equal(keys2);
keys2.Should().Equal(keys3);
}
[Fact]
public async Task VersionHistory_ThresholdsAndWeights_PreservedAcrossVersions()
{
// Arrange
var profileName = "config-preserved-profile";
var v1Thresholds = "{\"critical\": 9.0, \"high\": 7.0, \"medium\": 4.0}";
var v1Weights = "{\"vulnerability\": 1.0, \"configuration\": 0.8}";
var v1 = new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = profileName,
Version = 1,
IsActive = false,
Thresholds = v1Thresholds,
ScoringWeights = v1Weights
};
var v2Thresholds = "{\"critical\": 8.5, \"high\": 6.5, \"medium\": 3.5}";
var v2Weights = "{\"vulnerability\": 1.0, \"configuration\": 0.9, \"compliance\": 0.7}";
var v2 = new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = profileName,
Version = 2,
IsActive = true,
Thresholds = v2Thresholds,
ScoringWeights = v2Weights
};
await _repository.CreateAsync(v1);
await _repository.CreateAsync(v2);
// Act
var fetchedV1 = await _repository.GetByIdAsync(_tenantId, v1.Id);
var fetchedV2 = await _repository.GetByIdAsync(_tenantId, v2.Id);
// Assert - Both versions should preserve their original configuration
fetchedV1!.Thresholds.Should().Be(v1Thresholds);
fetchedV1.ScoringWeights.Should().Be(v1Weights);
fetchedV2!.Thresholds.Should().Be(v2Thresholds);
fetchedV2.ScoringWeights.Should().Be(v2Weights);
}
[Fact]
public async Task VersionHistory_DeleteOldVersion_NewerVersionsRemain()
{
// Arrange
var profileName = "delete-old-profile";
var v1 = new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = profileName,
Version = 1,
IsActive = false
};
var v2 = new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = profileName,
Version = 2,
IsActive = true
};
await _repository.CreateAsync(v1);
await _repository.CreateAsync(v2);
// Act - Delete v1
await _repository.DeleteAsync(_tenantId, v1.Id);
// Assert
var remaining = await _repository.GetVersionsByNameAsync(_tenantId, profileName);
remaining.Should().ContainSingle();
remaining[0].Version.Should().Be(2);
}
[Fact]
public async Task VersionHistory_MultiTenant_VersionsIsolated()
{
// Arrange - Create same profile name in different tenants
var profileName = "multi-tenant-profile";
var tenant1 = Guid.NewGuid().ToString();
var tenant2 = Guid.NewGuid().ToString();
await _repository.CreateAsync(new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = tenant1,
Name = profileName,
Version = 1,
IsActive = true,
Thresholds = "{\"tenant\": \"1\"}"
});
await _repository.CreateAsync(new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = tenant2,
Name = profileName,
Version = 5, // Different version
IsActive = true,
Thresholds = "{\"tenant\": \"2\"}"
});
// Act
var tenant1Profile = await _repository.GetActiveByNameAsync(tenant1, profileName);
var tenant2Profile = await _repository.GetActiveByNameAsync(tenant2, profileName);
// Assert - Tenants should have completely isolated versions
tenant1Profile!.Version.Should().Be(1);
tenant1Profile.Thresholds.Should().Contain("\"tenant\": \"1\"");
tenant2Profile!.Version.Should().Be(5);
tenant2Profile.Thresholds.Should().Contain("\"tenant\": \"2\"");
}
[Fact]
public async Task VersionHistory_DeactivateActiveVersion_NoActiveRemains()
{
// Arrange
var profileName = "deactivate-active-profile";
var v1 = new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = profileName,
Version = 1,
IsActive = true
};
await _repository.CreateAsync(v1);
// Act - Deactivate the only version
await _repository.DeactivateAsync(_tenantId, v1.Id);
// Assert - No active version should exist
var active = await _repository.GetActiveByNameAsync(_tenantId, profileName);
active.Should().BeNull();
// But the profile should still exist
var fetched = await _repository.GetByIdAsync(_tenantId, v1.Id);
fetched.Should().NotBeNull();
fetched!.IsActive.Should().BeFalse();
}
[Fact]
public async Task VersionHistory_UpdateDescription_DoesNotAffectVersion()
{
// Arrange
var profile = new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "update-desc-profile",
DisplayName = "Original Name",
Description = "Original description",
Version = 3,
IsActive = true
};
await _repository.CreateAsync(profile);
// Act - Update display name and description
var updated = new RiskProfileEntity
{
Id = profile.Id,
TenantId = _tenantId,
Name = profile.Name,
DisplayName = "Updated Name",
Description = "Updated description",
Version = profile.Version,
IsActive = true
};
await _repository.UpdateAsync(updated);
// Assert - Version should remain unchanged
var fetched = await _repository.GetByIdAsync(_tenantId, profile.Id);
fetched!.Version.Should().Be(3);
fetched.DisplayName.Should().Be("Updated Name");
fetched.Description.Should().Be("Updated description");
}
[Fact]
public async Task VersionHistory_TimestampsTracked_OnCreationAndUpdate()
{
// Arrange
var profile = new RiskProfileEntity
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
Name = "timestamp-profile",
Version = 1,
IsActive = true
};
// Act - Create
await _repository.CreateAsync(profile);
var created = await _repository.GetByIdAsync(_tenantId, profile.Id);
var createTime = created!.CreatedAt;
// Small delay
await Task.Delay(10);
// Update
var updated = new RiskProfileEntity
{
Id = profile.Id,
TenantId = _tenantId,
Name = profile.Name,
DisplayName = "Updated",
Version = 1,
IsActive = true
};
await _repository.UpdateAsync(updated);
var afterUpdate = await _repository.GetByIdAsync(_tenantId, profile.Id);
// Assert
afterUpdate!.CreatedAt.Should().Be(createTime); // CreatedAt should not change
afterUpdate.UpdatedAt.Should().BeOnOrAfter(createTime); // UpdatedAt should progress
}
}

View File

@@ -1,10 +1,11 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Node.Internal;
internal static class NodeEnvironmentScanner
{
private static readonly Regex EnvAssign = new("^\s*(ENV|ARG)\s+NODE_OPTIONS\s*(=|\s)(?<value>.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex EnvAssign = new(@"^\s*(ENV|ARG)\s+NODE_OPTIONS\s*(=|\s)(?<value>.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static IReadOnlyList<LanguageComponentRecord> Scan(LanguageAnalyzerContext context, IReadOnlyList<string> sourceRoots, CancellationToken cancellationToken)
{
@@ -34,6 +35,7 @@ internal static class NodeEnvironmentScanner
private static IEnumerable<LanguageComponentRecord> ScanDockerfile(LanguageAnalyzerContext context, string dockerfile)
{
var results = new List<LanguageComponentRecord>();
try
{
var lines = File.ReadAllLines(dockerfile);
@@ -46,17 +48,20 @@ internal static class NodeEnvironmentScanner
}
var value = match.Groups["value"].Value.Trim().Trim('"', '\'');
yield return BuildWarning(context, dockerfile, i + 1, value, source: "Dockerfile", reason: "NODE_OPTIONS");
results.Add(BuildWarning(context, dockerfile, i + 1, value, source: "Dockerfile", reason: "NODE_OPTIONS"));
}
}
catch (IOException)
{
yield break;
// Ignore IO errors
}
return results;
}
private static IEnumerable<LanguageComponentRecord> ScanEnvFile(LanguageAnalyzerContext context, string envFile)
{
var results = new List<LanguageComponentRecord>();
try
{
var lines = File.ReadAllLines(envFile);
@@ -75,13 +80,15 @@ internal static class NodeEnvironmentScanner
}
var value = parts[1].Trim().Trim('"', '\'');
yield return BuildWarning(context, envFile, i + 1, value, source: ".env", reason: "NODE_OPTIONS");
results.Add(BuildWarning(context, envFile, i + 1, value, source: ".env", reason: "NODE_OPTIONS"));
}
}
catch (IOException)
{
yield break;
// Ignore IO errors
}
return results;
}
private static LanguageComponentRecord BuildWarning(LanguageAnalyzerContext context, string filePath, int lineNumber, string value, string source, string reason)

View File

@@ -2,18 +2,18 @@ using StellaOps.Scanner.Analyzers.Lang.Node.Internal;
using StellaOps.Scanner.Analyzers.Lang.Node.Internal.Phase22;
namespace StellaOps.Scanner.Analyzers.Lang.Node;
public sealed class NodeLanguageAnalyzer : ILanguageAnalyzer
{
public string Id => "node";
public string DisplayName => "Node.js Analyzer";
public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
public sealed class NodeLanguageAnalyzer : ILanguageAnalyzer
{
public string Id => "node";
public string DisplayName => "Node.js Analyzer";
public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
var lockData = await NodeLockData.LoadAsync(context.RootPath, cancellationToken).ConfigureAwait(false);
var projectInput = NodeInputNormalizer.Normalize(context, cancellationToken);
var packages = NodePackageCollector.CollectPackages(context, lockData, projectInput, cancellationToken);
@@ -24,13 +24,13 @@ public sealed class NodeLanguageAnalyzer : ILanguageAnalyzer
var metadata = package.CreateMetadata();
var evidence = package.CreateEvidence();
writer.AddFromPurl(
analyzerId: Id,
purl: package.Purl,
name: package.Name,
version: package.Version,
type: "npm",
writer.AddFromPurl(
analyzerId: Id,
purl: package.Purl,
name: package.Name,
version: package.Version,
type: "npm",
metadata: metadata,
evidence: evidence,
usedByEntrypoint: package.IsUsedByEntrypoint);
@@ -68,14 +68,8 @@ public sealed class NodeLanguageAnalyzer : ILanguageAnalyzer
var phase22Records = await NodePhase22SampleLoader.TryLoadAsync(context.RootPath, cancellationToken).ConfigureAwait(false);
if (phase22Records.Count > 0)
{
writer.AddRange(phase22Records);
}
var observation = NodePhase22Analyzer.Analyze(context, cancellationToken);
if (observation.HasRecords)
{
var observationRecords = NodePhase22Exporter.ToComponentRecords(observation);
writer.AddRange(observationRecords);
writer.AddRange(phase22Records);
}
}
var runtimeRecords = RuntimeEvidenceLoader.Load(context, cancellationToken);
@@ -91,4 +85,3 @@ public sealed class NodeLanguageAnalyzer : ILanguageAnalyzer
}
}
}
}

View File

@@ -252,7 +252,7 @@ public sealed class EntryTraceRuntimeReconciler
? EntryTraceUnknownReason.RuntimeMatch
: EntryTraceUnknownReason.RuntimeMismatch;
var chain = process is null ? null : BuildProcessChain(procGraph, process.Value);
var chain = process is null ? null : BuildProcessChain(procGraph, process);
var message = result.Level == ConfidenceLevel.High
? $"Runtime process '{runtimePath}' matches EntryTrace prediction '{predictedPath}'."

View File

@@ -693,18 +693,6 @@ Global
{37F203A3-624E-4794-9C99-16CAC22C17DF}.Release|x64.Build.0 = Release|Any CPU
{37F203A3-624E-4794-9C99-16CAC22C17DF}.Release|x86.ActiveCfg = Release|Any CPU
{37F203A3-624E-4794-9C99-16CAC22C17DF}.Release|x86.Build.0 = Release|Any CPU
{3FF93987-A30A-4D50-8815-7CF3BB7CAE05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3FF93987-A30A-4D50-8815-7CF3BB7CAE05}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3FF93987-A30A-4D50-8815-7CF3BB7CAE05}.Debug|x64.ActiveCfg = Debug|Any CPU
{3FF93987-A30A-4D50-8815-7CF3BB7CAE05}.Debug|x64.Build.0 = Debug|Any CPU
{3FF93987-A30A-4D50-8815-7CF3BB7CAE05}.Debug|x86.ActiveCfg = Debug|Any CPU
{3FF93987-A30A-4D50-8815-7CF3BB7CAE05}.Debug|x86.Build.0 = Debug|Any CPU
{3FF93987-A30A-4D50-8815-7CF3BB7CAE05}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3FF93987-A30A-4D50-8815-7CF3BB7CAE05}.Release|Any CPU.Build.0 = Release|Any CPU
{3FF93987-A30A-4D50-8815-7CF3BB7CAE05}.Release|x64.ActiveCfg = Release|Any CPU
{3FF93987-A30A-4D50-8815-7CF3BB7CAE05}.Release|x64.Build.0 = Release|Any CPU
{3FF93987-A30A-4D50-8815-7CF3BB7CAE05}.Release|x86.ActiveCfg = Release|Any CPU
{3FF93987-A30A-4D50-8815-7CF3BB7CAE05}.Release|x86.Build.0 = Release|Any CPU
{AACE8717-0760-42F2-A225-8FCCE876FB65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AACE8717-0760-42F2-A225-8FCCE876FB65}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AACE8717-0760-42F2-A225-8FCCE876FB65}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -1377,18 +1365,6 @@ Global
{476EAADA-1B39-4049-ABE4-CCAC21FFE9E2}.Release|x64.Build.0 = Release|Any CPU
{476EAADA-1B39-4049-ABE4-CCAC21FFE9E2}.Release|x86.ActiveCfg = Release|Any CPU
{476EAADA-1B39-4049-ABE4-CCAC21FFE9E2}.Release|x86.Build.0 = Release|Any CPU
{0EF56124-E6E8-4E89-95DD-5A5D5FF05A98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0EF56124-E6E8-4E89-95DD-5A5D5FF05A98}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0EF56124-E6E8-4E89-95DD-5A5D5FF05A98}.Debug|x64.ActiveCfg = Debug|Any CPU
{0EF56124-E6E8-4E89-95DD-5A5D5FF05A98}.Debug|x64.Build.0 = Debug|Any CPU
{0EF56124-E6E8-4E89-95DD-5A5D5FF05A98}.Debug|x86.ActiveCfg = Debug|Any CPU
{0EF56124-E6E8-4E89-95DD-5A5D5FF05A98}.Debug|x86.Build.0 = Debug|Any CPU
{0EF56124-E6E8-4E89-95DD-5A5D5FF05A98}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0EF56124-E6E8-4E89-95DD-5A5D5FF05A98}.Release|Any CPU.Build.0 = Release|Any CPU
{0EF56124-E6E8-4E89-95DD-5A5D5FF05A98}.Release|x64.ActiveCfg = Release|Any CPU
{0EF56124-E6E8-4E89-95DD-5A5D5FF05A98}.Release|x64.Build.0 = Release|Any CPU
{0EF56124-E6E8-4E89-95DD-5A5D5FF05A98}.Release|x86.ActiveCfg = Release|Any CPU
{0EF56124-E6E8-4E89-95DD-5A5D5FF05A98}.Release|x86.Build.0 = Release|Any CPU
{0DBB9FC4-2E46-4C3E-BE88-2A8DCB59DB7D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0DBB9FC4-2E46-4C3E-BE88-2A8DCB59DB7D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0DBB9FC4-2E46-4C3E-BE88-2A8DCB59DB7D}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -1461,18 +1437,6 @@ Global
{D71B0DA5-80A3-419E-898D-40E77A9A7F19}.Release|x64.Build.0 = Release|Any CPU
{D71B0DA5-80A3-419E-898D-40E77A9A7F19}.Release|x86.ActiveCfg = Release|Any CPU
{D71B0DA5-80A3-419E-898D-40E77A9A7F19}.Release|x86.Build.0 = Release|Any CPU
{B2C877D9-B521-4901-8817-76B5DAA62FCE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C877D9-B521-4901-8817-76B5DAA62FCE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C877D9-B521-4901-8817-76B5DAA62FCE}.Debug|x64.ActiveCfg = Debug|Any CPU
{B2C877D9-B521-4901-8817-76B5DAA62FCE}.Debug|x64.Build.0 = Debug|Any CPU
{B2C877D9-B521-4901-8817-76B5DAA62FCE}.Debug|x86.ActiveCfg = Debug|Any CPU
{B2C877D9-B521-4901-8817-76B5DAA62FCE}.Debug|x86.Build.0 = Debug|Any CPU
{B2C877D9-B521-4901-8817-76B5DAA62FCE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2C877D9-B521-4901-8817-76B5DAA62FCE}.Release|Any CPU.Build.0 = Release|Any CPU
{B2C877D9-B521-4901-8817-76B5DAA62FCE}.Release|x64.ActiveCfg = Release|Any CPU
{B2C877D9-B521-4901-8817-76B5DAA62FCE}.Release|x64.Build.0 = Release|Any CPU
{B2C877D9-B521-4901-8817-76B5DAA62FCE}.Release|x86.ActiveCfg = Release|Any CPU
{B2C877D9-B521-4901-8817-76B5DAA62FCE}.Release|x86.Build.0 = Release|Any CPU
{08D3B6D0-3CE8-4F24-A6F1-BCAB01AD6278}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{08D3B6D0-3CE8-4F24-A6F1-BCAB01AD6278}.Debug|Any CPU.Build.0 = Debug|Any CPU
{08D3B6D0-3CE8-4F24-A6F1-BCAB01AD6278}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -1629,18 +1593,6 @@ Global
{DE9863B5-E6D6-4C5F-B52A-ED9E964008A3}.Release|x64.Build.0 = Release|Any CPU
{DE9863B5-E6D6-4C5F-B52A-ED9E964008A3}.Release|x86.ActiveCfg = Release|Any CPU
{DE9863B5-E6D6-4C5F-B52A-ED9E964008A3}.Release|x86.Build.0 = Release|Any CPU
{E380F242-031E-483E-8570-0EF7EA525C4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E380F242-031E-483E-8570-0EF7EA525C4F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E380F242-031E-483E-8570-0EF7EA525C4F}.Debug|x64.ActiveCfg = Debug|Any CPU
{E380F242-031E-483E-8570-0EF7EA525C4F}.Debug|x64.Build.0 = Debug|Any CPU
{E380F242-031E-483E-8570-0EF7EA525C4F}.Debug|x86.ActiveCfg = Debug|Any CPU
{E380F242-031E-483E-8570-0EF7EA525C4F}.Debug|x86.Build.0 = Debug|Any CPU
{E380F242-031E-483E-8570-0EF7EA525C4F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E380F242-031E-483E-8570-0EF7EA525C4F}.Release|Any CPU.Build.0 = Release|Any CPU
{E380F242-031E-483E-8570-0EF7EA525C4F}.Release|x64.ActiveCfg = Release|Any CPU
{E380F242-031E-483E-8570-0EF7EA525C4F}.Release|x64.Build.0 = Release|Any CPU
{E380F242-031E-483E-8570-0EF7EA525C4F}.Release|x86.ActiveCfg = Release|Any CPU
{E380F242-031E-483E-8570-0EF7EA525C4F}.Release|x86.Build.0 = Release|Any CPU
{42582C16-F5A9-417F-9D33-BC489925324F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{42582C16-F5A9-417F-9D33-BC489925324F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{42582C16-F5A9-417F-9D33-BC489925324F}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -2769,18 +2721,6 @@ Global
{6F482CF0-1D61-45EF-859C-6242C8BA08F4}.Release|x64.Build.0 = Release|Any CPU
{6F482CF0-1D61-45EF-859C-6242C8BA08F4}.Release|x86.ActiveCfg = Release|Any CPU
{6F482CF0-1D61-45EF-859C-6242C8BA08F4}.Release|x86.Build.0 = Release|Any CPU
{3AF4251B-91CF-4024-88B2-B77A9005604A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3AF4251B-91CF-4024-88B2-B77A9005604A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3AF4251B-91CF-4024-88B2-B77A9005604A}.Debug|x64.ActiveCfg = Debug|Any CPU
{3AF4251B-91CF-4024-88B2-B77A9005604A}.Debug|x64.Build.0 = Debug|Any CPU
{3AF4251B-91CF-4024-88B2-B77A9005604A}.Debug|x86.ActiveCfg = Debug|Any CPU
{3AF4251B-91CF-4024-88B2-B77A9005604A}.Debug|x86.Build.0 = Debug|Any CPU
{3AF4251B-91CF-4024-88B2-B77A9005604A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3AF4251B-91CF-4024-88B2-B77A9005604A}.Release|Any CPU.Build.0 = Release|Any CPU
{3AF4251B-91CF-4024-88B2-B77A9005604A}.Release|x64.ActiveCfg = Release|Any CPU
{3AF4251B-91CF-4024-88B2-B77A9005604A}.Release|x64.Build.0 = Release|Any CPU
{3AF4251B-91CF-4024-88B2-B77A9005604A}.Release|x86.ActiveCfg = Release|Any CPU
{3AF4251B-91CF-4024-88B2-B77A9005604A}.Release|x86.Build.0 = Release|Any CPU
{9EB54427-92C8-40F1-8425-26DF6ABF7109}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9EB54427-92C8-40F1-8425-26DF6ABF7109}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9EB54427-92C8-40F1-8425-26DF6ABF7109}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -3341,7 +3281,6 @@ Global
{B2967228-F8F7-4931-B257-1C63CB58CE1D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{6D52EC2B-0A1A-4693-A8EE-5AB32A4A3ED9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{37F203A3-624E-4794-9C99-16CAC22C17DF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{3FF93987-A30A-4D50-8815-7CF3BB7CAE05} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{AACE8717-0760-42F2-A225-8FCCE876FB65} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{4AAD6965-E879-44AD-A8ED-E1D713A3CD6D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{85D82A87-1F4A-4B1B-8422-5B7A7B7704E3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
@@ -3398,14 +3337,12 @@ Global
{06DC817F-A936-4F83-8929-E00622B32245} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{2C999476-0291-4161-B3E9-1AA99A3B1139} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{476EAADA-1B39-4049-ABE4-CCAC21FFE9E2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0EF56124-E6E8-4E89-95DD-5A5D5FF05A98} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0DBB9FC4-2E46-4C3E-BE88-2A8DCB59DB7D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{8A40142F-E8C8-4E86-BE70-7DD4AB1FFDEE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C9D20F74-EE5F-4C9E-9AB1-C03E90B34F92} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{50140A32-6D3C-47DB-983A-7166CBA51845} = {361838C4-72E2-1C48-5D76-CA6D1A861242}
{031979F2-6ABA-444F-A6A4-80115DC487CE} = {361838C4-72E2-1C48-5D76-CA6D1A861242}
{D71B0DA5-80A3-419E-898D-40E77A9A7F19} = {361838C4-72E2-1C48-5D76-CA6D1A861242}
{B2C877D9-B521-4901-8817-76B5DAA62FCE} = {361838C4-72E2-1C48-5D76-CA6D1A861242}
{08D3B6D0-3CE8-4F24-A6F1-BCAB01AD6278} = {361838C4-72E2-1C48-5D76-CA6D1A861242}
{7116DD6B-2491-49E1-AB27-5210E949F753} = {361838C4-72E2-1C48-5D76-CA6D1A861242}
{7DBE31A6-D2FD-499E-B675-4092723175AD} = {361838C4-72E2-1C48-5D76-CA6D1A861242}
@@ -3438,7 +3375,6 @@ Global
{166ECC12-EF41-266B-D99C-4764D5FBD04E} = {00227F43-A2B4-2312-24D4-35D99B2D62BA}
{60BA1521-B6FC-43F6-ABEF-4471A06289E1} = {166ECC12-EF41-266B-D99C-4764D5FBD04E}
{6F482CF0-1D61-45EF-859C-6242C8BA08F4} = {166ECC12-EF41-266B-D99C-4764D5FBD04E}
{3AF4251B-91CF-4024-88B2-B77A9005604A} = {166ECC12-EF41-266B-D99C-4764D5FBD04E}
{9EB54427-92C8-40F1-8425-26DF6ABF7109} = {166ECC12-EF41-266B-D99C-4764D5FBD04E}
{52B52BCE-54BA-45B7-8EC2-B547B540EE72} = {166ECC12-EF41-266B-D99C-4764D5FBD04E}
{F8130985-63AB-4102-8CCA-3537829250AD} = {166ECC12-EF41-266B-D99C-4764D5FBD04E}

View File

@@ -0,0 +1,181 @@
using System.Reflection;
namespace StellaOps.Infrastructure.Postgres.Migrations;
/// <summary>
/// Interface for running database migrations.
/// </summary>
public interface IMigrationRunner
{
/// <summary>
/// Gets the schema name for this migration runner.
/// </summary>
string SchemaName { get; }
/// <summary>
/// Gets the module name for this migration runner.
/// </summary>
string ModuleName { get; }
/// <summary>
/// Runs pending migrations from the specified path.
/// </summary>
/// <param name="migrationsPath">Path to directory containing SQL migration files.</param>
/// <param name="options">Migration execution options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of migration execution.</returns>
Task<MigrationResult> RunAsync(
string migrationsPath,
MigrationRunOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Runs pending migrations from embedded resources in an assembly.
/// </summary>
/// <param name="assembly">Assembly containing embedded migration resources.</param>
/// <param name="resourcePrefix">Optional prefix to filter resources.</param>
/// <param name="options">Migration execution options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of migration execution.</returns>
Task<MigrationResult> RunFromAssemblyAsync(
Assembly assembly,
string? resourcePrefix = null,
MigrationRunOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current migration version (latest applied migration).
/// </summary>
Task<string?> GetCurrentVersionAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets all applied migrations.
/// </summary>
Task<IReadOnlyList<MigrationInfo>> GetAppliedMigrationInfoAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Validates checksums of applied migrations against source files.
/// </summary>
/// <param name="assembly">Assembly containing embedded migration resources.</param>
/// <param name="resourcePrefix">Optional prefix to filter resources.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of checksum validation errors, empty if all valid.</returns>
Task<IReadOnlyList<string>> ValidateChecksumsAsync(
Assembly assembly,
string? resourcePrefix = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Options for migration execution.
/// </summary>
public sealed class MigrationRunOptions
{
/// <summary>
/// Filter migrations by category. If null, all categories are included.
/// </summary>
public MigrationCategory? CategoryFilter { get; set; }
/// <summary>
/// If true, only show what would be executed without applying.
/// </summary>
public bool DryRun { get; set; }
/// <summary>
/// Timeout in seconds for individual migration execution. Default: 300.
/// </summary>
public int TimeoutSeconds { get; set; } = 300;
/// <summary>
/// If true, validate checksums before applying new migrations.
/// </summary>
public bool ValidateChecksums { get; set; } = true;
/// <summary>
/// If true, fail if checksum validation errors are found.
/// </summary>
public bool FailOnChecksumMismatch { get; set; } = true;
}
/// <summary>
/// Result of a migration execution.
/// </summary>
public sealed class MigrationResult
{
/// <summary>
/// Whether the migration run was successful.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Number of migrations applied.
/// </summary>
public int AppliedCount { get; init; }
/// <summary>
/// Number of migrations skipped (already applied).
/// </summary>
public int SkippedCount { get; init; }
/// <summary>
/// Number of migrations filtered out by category.
/// </summary>
public int FilteredCount { get; init; }
/// <summary>
/// Total duration in milliseconds.
/// </summary>
public long DurationMs { get; init; }
/// <summary>
/// Details of applied migrations.
/// </summary>
public IReadOnlyList<AppliedMigrationDetail> AppliedMigrations { get; init; } = [];
/// <summary>
/// Checksum validation errors, if any.
/// </summary>
public IReadOnlyList<string> ChecksumErrors { get; init; } = [];
/// <summary>
/// Error message if migration failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static MigrationResult Successful(
int appliedCount,
int skippedCount,
int filteredCount,
long durationMs,
IReadOnlyList<AppliedMigrationDetail> appliedMigrations) => new()
{
Success = true,
AppliedCount = appliedCount,
SkippedCount = skippedCount,
FilteredCount = filteredCount,
DurationMs = durationMs,
AppliedMigrations = appliedMigrations
};
/// <summary>
/// Creates a failed result.
/// </summary>
public static MigrationResult Failed(string errorMessage, IReadOnlyList<string>? checksumErrors = null) => new()
{
Success = false,
ErrorMessage = errorMessage,
ChecksumErrors = checksumErrors ?? []
};
}
/// <summary>
/// Details of an applied migration.
/// </summary>
public sealed record AppliedMigrationDetail(
string Name,
MigrationCategory Category,
long DurationMs,
bool WasDryRun);

View File

@@ -0,0 +1,88 @@
namespace StellaOps.Infrastructure.Postgres.Migrations;
/// <summary>
/// Categorizes migrations by when they should be executed.
/// </summary>
public enum MigrationCategory
{
/// <summary>
/// Automatic migrations that run at application startup.
/// Must be non-breaking and complete quickly (< 60s).
/// Prefix: 001-099
/// </summary>
Startup,
/// <summary>
/// Manual migrations that require CLI execution before deployment.
/// Used for breaking changes that need coordination.
/// Prefix: 100-199
/// </summary>
Release,
/// <summary>
/// Seed data that is inserted once.
/// Prefix: S001-S999
/// </summary>
Seed,
/// <summary>
/// Long-running data migrations that run as background jobs.
/// Prefix: DM001-DM999
/// </summary>
Data
}
/// <summary>
/// Extension methods for migration category operations.
/// </summary>
public static class MigrationCategoryExtensions
{
/// <summary>
/// Determines the category of a migration based on its filename.
/// </summary>
public static MigrationCategory GetCategory(string migrationName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(migrationName);
// Extract numeric prefix
var name = Path.GetFileNameWithoutExtension(migrationName);
if (name.StartsWith("DM", StringComparison.OrdinalIgnoreCase))
{
return MigrationCategory.Data;
}
if (name.StartsWith("S", StringComparison.OrdinalIgnoreCase) && char.IsDigit(name.ElementAtOrDefault(1)))
{
return MigrationCategory.Seed;
}
// Try to parse leading digits
var numericPrefix = new string(name.TakeWhile(char.IsDigit).ToArray());
if (int.TryParse(numericPrefix, out var prefix))
{
return prefix switch
{
>= 1 and <= 99 => MigrationCategory.Startup,
>= 100 and <= 199 => MigrationCategory.Release,
>= 200 => MigrationCategory.Release,
_ => MigrationCategory.Startup
};
}
// Default to startup for unknown patterns
return MigrationCategory.Startup;
}
/// <summary>
/// Returns true if this migration should run automatically at startup.
/// </summary>
public static bool IsAutomatic(this MigrationCategory category) =>
category is MigrationCategory.Startup or MigrationCategory.Seed;
/// <summary>
/// Returns true if this migration requires manual CLI execution.
/// </summary>
public static bool RequiresManualExecution(this MigrationCategory category) =>
category is MigrationCategory.Release or MigrationCategory.Data;
}

View File

@@ -0,0 +1,383 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.Infrastructure.Postgres.Migrations;
/// <summary>
/// Extension methods for registering migration services.
/// </summary>
public static class MigrationServiceExtensions
{
/// <summary>
/// Adds a startup migration host for the specified module.
/// </summary>
/// <typeparam name="TOptions">Options type containing the connection string.</typeparam>
/// <param name="services">Service collection.</param>
/// <param name="schemaName">PostgreSQL schema name for this module.</param>
/// <param name="moduleName">Module name for logging.</param>
/// <param name="migrationsAssembly">Assembly containing embedded SQL migrations.</param>
/// <param name="connectionStringSelector">Function to extract connection string from options.</param>
/// <param name="configureOptions">Optional configuration for migration behavior.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddStartupMigrations<TOptions>(
this IServiceCollection services,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
Func<TOptions, string> connectionStringSelector,
Action<StartupMigrationOptions>? configureOptions = null)
where TOptions : class
{
var migrationOptions = new StartupMigrationOptions();
configureOptions?.Invoke(migrationOptions);
services.AddHostedService(sp =>
{
var options = sp.GetRequiredService<IOptions<TOptions>>().Value;
var connectionString = connectionStringSelector(options);
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger($"Migration.{moduleName}");
var lifetime = sp.GetRequiredService<IHostApplicationLifetime>();
return new GenericStartupMigrationHost(
connectionString,
schemaName,
moduleName,
migrationsAssembly,
logger,
lifetime,
migrationOptions);
});
return services;
}
/// <summary>
/// Adds a startup migration host using PostgresOptions.
/// </summary>
public static IServiceCollection AddStartupMigrations(
this IServiceCollection services,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
Action<StartupMigrationOptions>? configureOptions = null)
{
return services.AddStartupMigrations<PostgresOptions>(
schemaName,
moduleName,
migrationsAssembly,
options => options.ConnectionString,
configureOptions);
}
/// <summary>
/// Adds a migration runner as a singleton for manual/CLI migration execution.
/// </summary>
public static IServiceCollection AddMigrationRunner<TOptions>(
this IServiceCollection services,
string schemaName,
string moduleName,
Func<TOptions, string> connectionStringSelector)
where TOptions : class
{
services.AddSingleton(sp =>
{
var options = sp.GetRequiredService<IOptions<TOptions>>().Value;
var connectionString = connectionStringSelector(options);
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger($"Migration.{moduleName}");
return new MigrationRunner(connectionString, schemaName, moduleName, logger);
});
return services;
}
/// <summary>
/// Adds the migration status service for querying migration state.
/// </summary>
public static IServiceCollection AddMigrationStatus<TOptions>(
this IServiceCollection services,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
Func<TOptions, string> connectionStringSelector)
where TOptions : class
{
services.AddSingleton<IMigrationStatusService>(sp =>
{
var options = sp.GetRequiredService<IOptions<TOptions>>().Value;
var connectionString = connectionStringSelector(options);
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger($"MigrationStatus.{moduleName}");
return new MigrationStatusService(
connectionString,
schemaName,
moduleName,
migrationsAssembly,
logger);
});
return services;
}
private sealed class GenericStartupMigrationHost : StartupMigrationHost
{
public GenericStartupMigrationHost(
string connectionString,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
ILogger logger,
IHostApplicationLifetime lifetime,
StartupMigrationOptions options)
: base(connectionString, schemaName, moduleName, migrationsAssembly, logger, lifetime, options)
{
}
}
}
/// <summary>
/// Service for querying migration status without applying migrations.
/// </summary>
public interface IMigrationStatusService
{
/// <summary>
/// Gets the current migration status for the module.
/// </summary>
Task<MigrationStatus> GetStatusAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Current migration status for a module.
/// </summary>
public sealed record MigrationStatus
{
/// <summary>
/// Module name.
/// </summary>
public required string ModuleName { get; init; }
/// <summary>
/// Schema name.
/// </summary>
public required string SchemaName { get; init; }
/// <summary>
/// Number of applied migrations.
/// </summary>
public int AppliedCount { get; init; }
/// <summary>
/// Number of pending startup migrations.
/// </summary>
public int PendingStartupCount { get; init; }
/// <summary>
/// Number of pending release migrations.
/// </summary>
public int PendingReleaseCount { get; init; }
/// <summary>
/// Last applied migration name.
/// </summary>
public string? LastAppliedMigration { get; init; }
/// <summary>
/// When the last migration was applied.
/// </summary>
public DateTimeOffset? LastAppliedAt { get; init; }
/// <summary>
/// List of pending migrations.
/// </summary>
public IReadOnlyList<PendingMigrationInfo> PendingMigrations { get; init; } = [];
/// <summary>
/// Any checksum mismatches detected.
/// </summary>
public IReadOnlyList<string> ChecksumErrors { get; init; } = [];
/// <summary>
/// Whether the database is up to date (no pending startup/release migrations).
/// </summary>
public bool IsUpToDate => PendingStartupCount == 0 && PendingReleaseCount == 0;
/// <summary>
/// Whether there are blocking issues (pending release migrations or checksum errors).
/// </summary>
public bool HasBlockingIssues => PendingReleaseCount > 0 || ChecksumErrors.Count > 0;
}
/// <summary>
/// Information about a pending migration.
/// </summary>
public sealed record PendingMigrationInfo(string Name, MigrationCategory Category);
/// <summary>
/// Implementation of migration status service.
/// </summary>
internal sealed class MigrationStatusService : IMigrationStatusService
{
private readonly string _connectionString;
private readonly string _schemaName;
private readonly string _moduleName;
private readonly Assembly _migrationsAssembly;
private readonly ILogger _logger;
public MigrationStatusService(
string connectionString,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
ILogger logger)
{
_connectionString = connectionString;
_schemaName = schemaName;
_moduleName = moduleName;
_migrationsAssembly = migrationsAssembly;
_logger = logger;
}
public async Task<MigrationStatus> GetStatusAsync(CancellationToken cancellationToken = default)
{
await using var connection = new Npgsql.NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
// Check if migrations table exists
var tableExists = await CheckMigrationsTableExistsAsync(connection, cancellationToken).ConfigureAwait(false);
var applied = new Dictionary<string, (string Checksum, DateTimeOffset AppliedAt)>(StringComparer.Ordinal);
if (tableExists)
{
applied = await GetAppliedMigrationsAsync(connection, cancellationToken).ConfigureAwait(false);
}
// Load all migrations from assembly
var allMigrations = LoadMigrationsFromAssembly();
// Find pending and validate checksums
var pending = new List<PendingMigrationInfo>();
var checksumErrors = new List<string>();
foreach (var migration in allMigrations)
{
if (applied.TryGetValue(migration.Name, out var appliedInfo))
{
if (!string.Equals(migration.Checksum, appliedInfo.Checksum, StringComparison.Ordinal))
{
checksumErrors.Add(
$"Checksum mismatch for '{migration.Name}': " +
$"expected '{migration.Checksum[..16]}...', found '{appliedInfo.Checksum[..16]}...'");
}
}
else
{
pending.Add(new PendingMigrationInfo(migration.Name, migration.Category));
}
}
var lastApplied = applied
.OrderByDescending(kvp => kvp.Value.AppliedAt)
.FirstOrDefault();
return new MigrationStatus
{
ModuleName = _moduleName,
SchemaName = _schemaName,
AppliedCount = applied.Count,
PendingStartupCount = pending.Count(p => p.Category.IsAutomatic()),
PendingReleaseCount = pending.Count(p => p.Category.RequiresManualExecution()),
LastAppliedMigration = lastApplied.Key,
LastAppliedAt = lastApplied.Key is not null ? lastApplied.Value.AppliedAt : null,
PendingMigrations = pending,
ChecksumErrors = checksumErrors
};
}
private async Task<bool> CheckMigrationsTableExistsAsync(
Npgsql.NpgsqlConnection connection,
CancellationToken cancellationToken)
{
await using var command = new Npgsql.NpgsqlCommand(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = @schema
AND table_name = 'schema_migrations'
)
""",
connection);
command.Parameters.AddWithValue("schema", _schemaName);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is true;
}
private async Task<Dictionary<string, (string Checksum, DateTimeOffset AppliedAt)>> GetAppliedMigrationsAsync(
Npgsql.NpgsqlConnection connection,
CancellationToken cancellationToken)
{
var result = new Dictionary<string, (string, DateTimeOffset)>(StringComparer.Ordinal);
await using var command = new Npgsql.NpgsqlCommand(
$"SELECT migration_name, checksum, applied_at FROM {_schemaName}.schema_migrations",
connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
result[reader.GetString(0)] = (reader.GetString(1), reader.GetFieldValue<DateTimeOffset>(2));
}
return result;
}
private List<(string Name, MigrationCategory Category, string Checksum)> LoadMigrationsFromAssembly()
{
var migrations = new List<(string, MigrationCategory, string)>();
var resourceNames = _migrationsAssembly.GetManifestResourceNames()
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
.OrderBy(name => name);
foreach (var resourceName in resourceNames)
{
using var stream = _migrationsAssembly.GetManifestResourceStream(resourceName);
if (stream is null) continue;
using var reader = new StreamReader(stream);
var content = reader.ReadToEnd();
var fileName = ExtractFileName(resourceName);
var category = MigrationCategoryExtensions.GetCategory(fileName);
var checksum = ComputeChecksum(content);
migrations.Add((fileName, category, checksum));
}
return migrations;
}
private static string ExtractFileName(string resourceName)
{
var parts = resourceName.Split('.');
for (var i = parts.Length - 1; i >= 0; i--)
{
if (parts[i].EndsWith("sql", StringComparison.OrdinalIgnoreCase))
{
return i > 0 ? $"{parts[i - 1]}.sql" : parts[i];
}
}
return resourceName;
}
private static string ComputeChecksum(string content)
{
var normalized = content.Replace("\r\n", "\n").Replace("\r", "\n");
var bytes = System.Text.Encoding.UTF8.GetBytes(normalized);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
}

View File

@@ -0,0 +1,479 @@
using System.Diagnostics;
using System.Reflection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace StellaOps.Infrastructure.Postgres.Migrations;
/// <summary>
/// Hosted service that runs database migrations at application startup.
/// Uses advisory locks to prevent race conditions in multi-instance deployments.
/// </summary>
/// <remarks>
/// This service:
/// - Acquires an advisory lock to prevent concurrent migrations
/// - Validates checksums of already-applied migrations
/// - Blocks startup if pending release migrations exist
/// - Runs only Category A (startup) and seed migrations automatically
/// </remarks>
public abstract class StartupMigrationHost : IHostedService
{
private readonly string _connectionString;
private readonly string _schemaName;
private readonly string _moduleName;
private readonly Assembly _migrationsAssembly;
private readonly ILogger _logger;
private readonly IHostApplicationLifetime _lifetime;
private readonly StartupMigrationOptions _options;
/// <summary>
/// Creates a new startup migration host.
/// </summary>
protected StartupMigrationHost(
string connectionString,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
ILogger logger,
IHostApplicationLifetime lifetime,
StartupMigrationOptions? options = null)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_schemaName = schemaName ?? throw new ArgumentNullException(nameof(schemaName));
_moduleName = moduleName ?? throw new ArgumentNullException(nameof(moduleName));
_migrationsAssembly = migrationsAssembly ?? throw new ArgumentNullException(nameof(migrationsAssembly));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
_options = options ?? new StartupMigrationOptions();
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
if (!_options.Enabled)
{
_logger.LogInformation("Migration: Startup migrations disabled for {Module}.", _moduleName);
return;
}
var sw = Stopwatch.StartNew();
try
{
_logger.LogInformation("Migration: Starting migration check for {Module}...", _moduleName);
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
// Step 1: Acquire advisory lock
var lockKey = ComputeLockKey(_schemaName);
if (!await TryAcquireLockAsync(connection, lockKey, cancellationToken).ConfigureAwait(false))
{
_logger.LogError(
"Migration: Failed to acquire lock for {Module} within {Timeout}s. Another instance may be running migrations.",
_moduleName, _options.LockTimeoutSeconds);
if (_options.FailOnLockTimeout)
{
_lifetime.StopApplication();
throw new InvalidOperationException(
$"Could not acquire migration lock for {_moduleName} within {_options.LockTimeoutSeconds} seconds.");
}
return;
}
_logger.LogDebug("Migration: Lock acquired for {Module}.", _moduleName);
try
{
// Step 2: Ensure schema and migrations table exist
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
await EnsureMigrationsTableAsync(connection, cancellationToken).ConfigureAwait(false);
// Step 3: Load and categorize migrations
var allMigrations = LoadMigrationsFromAssembly();
var appliedMigrations = await GetAppliedMigrationsAsync(connection, cancellationToken)
.ConfigureAwait(false);
// Step 4: Validate checksums
var checksumErrors = ValidateChecksums(allMigrations, appliedMigrations);
if (checksumErrors.Count > 0)
{
foreach (var error in checksumErrors)
{
_logger.LogError("Migration: {Error}", error);
}
if (_options.FailOnChecksumMismatch)
{
_lifetime.StopApplication();
throw new InvalidOperationException(
$"Migration checksum validation failed for {_moduleName}. See logs for details.");
}
}
// Step 5: Check for pending release migrations
var pendingRelease = allMigrations
.Where(m => !appliedMigrations.ContainsKey(m.Name))
.Where(m => m.Category.RequiresManualExecution())
.ToList();
if (pendingRelease.Count > 0)
{
_logger.LogError(
"Migration: {Count} pending release migration(s) require manual execution for {Module}:",
pendingRelease.Count, _moduleName);
foreach (var migration in pendingRelease)
{
_logger.LogError(" - {Migration} (Category: {Category})", migration.Name, migration.Category);
}
_logger.LogError("Run: stellaops db migrate --module {Module} --category release", _moduleName);
if (_options.FailOnPendingReleaseMigrations)
{
_lifetime.StopApplication();
throw new InvalidOperationException(
$"Pending release migrations block startup for {_moduleName}. Run CLI migration first.");
}
}
// Step 6: Execute pending startup migrations
var pendingStartup = allMigrations
.Where(m => !appliedMigrations.ContainsKey(m.Name))
.Where(m => m.Category.IsAutomatic())
.OrderBy(m => m.Name)
.ToList();
if (pendingStartup.Count == 0)
{
_logger.LogInformation("Migration: Database is up to date for {Module}.", _moduleName);
}
else
{
_logger.LogInformation(
"Migration: {Count} pending startup migration(s) for {Module}.",
pendingStartup.Count, _moduleName);
foreach (var migration in pendingStartup)
{
await ApplyMigrationAsync(connection, migration, cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation(
"Migration: Applied {Count} migration(s) for {Module} in {Elapsed}ms.",
pendingStartup.Count, _moduleName, sw.ElapsedMilliseconds);
}
}
finally
{
// Step 7: Release lock
await ReleaseLockAsync(connection, lockKey, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Migration: Lock released for {Module}.", _moduleName);
}
}
catch (Exception ex) when (ex is not InvalidOperationException)
{
_logger.LogError(ex, "Migration: Failed for {Module}.", _moduleName);
if (_options.FailOnMigrationError)
{
_lifetime.StopApplication();
throw;
}
}
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private async Task<bool> TryAcquireLockAsync(
NpgsqlConnection connection,
long lockKey,
CancellationToken cancellationToken)
{
var timeout = TimeSpan.FromSeconds(_options.LockTimeoutSeconds);
var deadline = DateTime.UtcNow.Add(timeout);
var retryDelay = TimeSpan.FromMilliseconds(500);
while (DateTime.UtcNow < deadline)
{
await using var command = new NpgsqlCommand(
"SELECT pg_try_advisory_lock(@key)",
connection);
command.Parameters.AddWithValue("key", lockKey);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
if (result is true)
{
return true;
}
_logger.LogDebug(
"Migration: Lock for {Module} is held by another instance, retrying in {Delay}ms...",
_moduleName, retryDelay.TotalMilliseconds);
await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false);
// Exponential backoff up to 5 seconds
retryDelay = TimeSpan.FromMilliseconds(Math.Min(retryDelay.TotalMilliseconds * 1.5, 5000));
}
return false;
}
private async Task ReleaseLockAsync(NpgsqlConnection connection, long lockKey, CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
"SELECT pg_advisory_unlock(@key)",
connection);
command.Parameters.AddWithValue("key", lockKey);
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
}
private async Task EnsureSchemaAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
$"CREATE SCHEMA IF NOT EXISTS {_schemaName}",
connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task EnsureMigrationsTableAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
$"""
CREATE TABLE IF NOT EXISTS {_schemaName}.schema_migrations (
migration_name TEXT PRIMARY KEY,
category TEXT NOT NULL DEFAULT 'startup',
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
applied_by TEXT,
duration_ms INT,
CONSTRAINT valid_category CHECK (category IN ('startup', 'release', 'seed', 'data'))
);
CREATE INDEX IF NOT EXISTS idx_schema_migrations_applied_at
ON {_schemaName}.schema_migrations(applied_at DESC);
""",
connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<Dictionary<string, AppliedMigration>> GetAppliedMigrationsAsync(
NpgsqlConnection connection,
CancellationToken cancellationToken)
{
var result = new Dictionary<string, AppliedMigration>(StringComparer.Ordinal);
await using var command = new NpgsqlCommand(
$"SELECT migration_name, category, checksum, applied_at FROM {_schemaName}.schema_migrations",
connection);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
var name = reader.GetString(0);
result[name] = new AppliedMigration(
Name: name,
Category: reader.GetString(1),
Checksum: reader.GetString(2),
AppliedAt: reader.GetFieldValue<DateTimeOffset>(3));
}
return result;
}
private List<PendingMigration> LoadMigrationsFromAssembly()
{
var migrations = new List<PendingMigration>();
var resourceNames = _migrationsAssembly.GetManifestResourceNames()
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
.OrderBy(name => name);
foreach (var resourceName in resourceNames)
{
using var stream = _migrationsAssembly.GetManifestResourceStream(resourceName);
if (stream is null) continue;
using var reader = new StreamReader(stream);
var content = reader.ReadToEnd();
var fileName = ExtractFileName(resourceName);
var category = MigrationCategoryExtensions.GetCategory(fileName);
var checksum = ComputeChecksum(content);
migrations.Add(new PendingMigration(
Name: fileName,
ResourceName: resourceName,
Category: category,
Checksum: checksum,
Content: content));
}
return migrations;
}
private List<string> ValidateChecksums(
List<PendingMigration> allMigrations,
Dictionary<string, AppliedMigration> appliedMigrations)
{
var errors = new List<string>();
foreach (var migration in allMigrations)
{
if (appliedMigrations.TryGetValue(migration.Name, out var applied))
{
if (!string.Equals(migration.Checksum, applied.Checksum, StringComparison.Ordinal))
{
errors.Add(
$"Checksum mismatch for '{migration.Name}': " +
$"expected '{migration.Checksum[..16]}...', found '{applied.Checksum[..16]}...'");
}
}
}
return errors;
}
private async Task ApplyMigrationAsync(
NpgsqlConnection connection,
PendingMigration migration,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Migration: Applying {Migration} ({Category})...",
migration.Name, migration.Category);
var sw = Stopwatch.StartNew();
await using var transaction = await connection.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
try
{
// Execute migration SQL
await using (var migrationCommand = new NpgsqlCommand(migration.Content, connection, transaction))
{
migrationCommand.CommandTimeout = _options.MigrationTimeoutSeconds;
await migrationCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
// Record migration
await using (var recordCommand = new NpgsqlCommand(
$"""
INSERT INTO {_schemaName}.schema_migrations
(migration_name, category, checksum, duration_ms, applied_by)
VALUES (@name, @category, @checksum, @duration, @applied_by)
ON CONFLICT (migration_name) DO NOTHING
""",
connection,
transaction))
{
recordCommand.Parameters.AddWithValue("name", migration.Name);
recordCommand.Parameters.AddWithValue("category", migration.Category.ToString().ToLowerInvariant());
recordCommand.Parameters.AddWithValue("checksum", migration.Checksum);
recordCommand.Parameters.AddWithValue("duration", (int)sw.ElapsedMilliseconds);
recordCommand.Parameters.AddWithValue("applied_by", Environment.MachineName);
await recordCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Migration: {Migration} completed in {Duration}ms.",
migration.Name, sw.ElapsedMilliseconds);
}
catch
{
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
throw;
}
}
private static long ComputeLockKey(string schemaName)
{
// Use a deterministic hash of the schema name as the lock key
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(schemaName));
return BitConverter.ToInt64(hash, 0);
}
private static string ComputeChecksum(string content)
{
// Normalize line endings for consistent checksums across platforms
var normalized = content.Replace("\r\n", "\n").Replace("\r", "\n");
var bytes = System.Text.Encoding.UTF8.GetBytes(normalized);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
private static string ExtractFileName(string resourceName)
{
var lastSlash = resourceName.LastIndexOf('/');
var lastDot = resourceName.LastIndexOf('.');
// Handle namespace-style resource names (e.g., "Namespace.Migrations.001_schema.sql")
if (lastSlash < 0)
{
// Find the filename by looking for pattern like "001_" or "S001_"
var parts = resourceName.Split('.');
for (var i = parts.Length - 1; i >= 0; i--)
{
if (parts[i].EndsWith("sql", StringComparison.OrdinalIgnoreCase))
{
return i > 0 ? $"{parts[i - 1]}.sql" : parts[i];
}
}
}
return lastSlash >= 0 ? resourceName[(lastSlash + 1)..] : resourceName;
}
private record AppliedMigration(string Name, string Category, string Checksum, DateTimeOffset AppliedAt);
private record PendingMigration(string Name, string ResourceName, MigrationCategory Category, string Checksum, string Content);
}
/// <summary>
/// Options for startup migration behavior.
/// </summary>
public sealed class StartupMigrationOptions
{
/// <summary>
/// Whether to run migrations at startup. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Timeout in seconds for acquiring the advisory lock. Default: 120.
/// </summary>
public int LockTimeoutSeconds { get; set; } = 120;
/// <summary>
/// Timeout in seconds for individual migration execution. Default: 300.
/// </summary>
public int MigrationTimeoutSeconds { get; set; } = 300;
/// <summary>
/// Whether to fail startup if lock cannot be acquired. Default: true.
/// </summary>
public bool FailOnLockTimeout { get; set; } = true;
/// <summary>
/// Whether to fail startup if checksum validation fails. Default: true.
/// </summary>
public bool FailOnChecksumMismatch { get; set; } = true;
/// <summary>
/// Whether to fail startup if there are pending release migrations. Default: true.
/// </summary>
public bool FailOnPendingReleaseMigrations { get; set; } = true;
/// <summary>
/// Whether to fail startup if migration execution fails. Default: true.
/// </summary>
public bool FailOnMigrationError { get; set; } = true;
}

View File

@@ -0,0 +1,205 @@
using FluentAssertions;
using StellaOps.Infrastructure.Postgres.Migrations;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
public class MigrationCategoryTests
{
#region GetCategory Tests - Startup Migrations (001-099)
[Theory]
[InlineData("001_initial_schema.sql", MigrationCategory.Startup)]
[InlineData("001_initial_schema", MigrationCategory.Startup)]
[InlineData("01_short_prefix.sql", MigrationCategory.Startup)]
[InlineData("1_single_digit.sql", MigrationCategory.Startup)]
[InlineData("050_middle_range.sql", MigrationCategory.Startup)]
[InlineData("099_last_startup.sql", MigrationCategory.Startup)]
public void GetCategory_StartupMigrations_ReturnsStartup(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
#endregion
#region GetCategory Tests - Release Migrations (100-199, 200+)
[Theory]
[InlineData("100_drop_legacy_columns.sql", MigrationCategory.Release)]
[InlineData("150_rename_table.sql", MigrationCategory.Release)]
[InlineData("199_last_release.sql", MigrationCategory.Release)]
[InlineData("200_major_version.sql", MigrationCategory.Release)]
[InlineData("250_another_major.sql", MigrationCategory.Release)]
[InlineData("999_very_high_number.sql", MigrationCategory.Release)]
public void GetCategory_ReleaseMigrations_ReturnsRelease(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
#endregion
#region GetCategory Tests - Seed Migrations (S001-S999)
[Theory]
[InlineData("S001_default_roles.sql", MigrationCategory.Seed)]
[InlineData("S100_builtin_policies.sql", MigrationCategory.Seed)]
[InlineData("S999_last_seed.sql", MigrationCategory.Seed)]
[InlineData("s001_lowercase.sql", MigrationCategory.Seed)]
public void GetCategory_SeedMigrations_ReturnsSeed(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
[Theory]
[InlineData("Schema_setup.sql")] // S followed by non-digit
[InlineData("Setup_tables.sql")]
public void GetCategory_SPrefix_NotFollowedByDigit_ReturnsStartup(string migrationName)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(MigrationCategory.Startup);
}
#endregion
#region GetCategory Tests - Data Migrations (DM001-DM999)
[Theory]
[InlineData("DM001_BackfillTenantIds.sql", MigrationCategory.Data)]
[InlineData("DM100_MigrateUserPrefs.sql", MigrationCategory.Data)]
[InlineData("DM999_FinalDataMigration.sql", MigrationCategory.Data)]
[InlineData("dm001_lowercase.sql", MigrationCategory.Data)]
public void GetCategory_DataMigrations_ReturnsData(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
#endregion
#region GetCategory Tests - Edge Cases
[Fact]
public void GetCategory_NullMigrationName_ThrowsArgumentNullException()
{
var act = () => MigrationCategoryExtensions.GetCategory(null!);
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void GetCategory_EmptyMigrationName_ThrowsArgumentException()
{
var act = () => MigrationCategoryExtensions.GetCategory(string.Empty);
act.Should().Throw<ArgumentException>();
}
[Fact]
public void GetCategory_WhitespaceMigrationName_ThrowsArgumentException()
{
var act = () => MigrationCategoryExtensions.GetCategory(" ");
act.Should().Throw<ArgumentException>();
}
[Theory]
[InlineData("no_prefix_migration.sql", MigrationCategory.Startup)]
[InlineData("migration.sql", MigrationCategory.Startup)]
[InlineData("abc_123.sql", MigrationCategory.Startup)]
public void GetCategory_UnknownPattern_DefaultsToStartup(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
[Theory]
[InlineData("migrations/subfolder/001_test.sql", MigrationCategory.Startup)]
[InlineData("100_release.SQL", MigrationCategory.Release)] // Different extension case
[InlineData("001_test", MigrationCategory.Startup)] // No extension
public void GetCategory_PathVariations_ExtractsCorrectly(string migrationName, MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
[Fact]
public void GetCategory_ZeroPrefix_ReturnsStartup()
{
// 0 should default to Startup as per the switch expression
var result = MigrationCategoryExtensions.GetCategory("0_zero_prefix.sql");
result.Should().Be(MigrationCategory.Startup);
}
#endregion
#region IsAutomatic Tests
[Theory]
[InlineData(MigrationCategory.Startup, true)]
[InlineData(MigrationCategory.Seed, true)]
[InlineData(MigrationCategory.Release, false)]
[InlineData(MigrationCategory.Data, false)]
public void IsAutomatic_ReturnsExpectedValue(MigrationCategory category, bool expected)
{
var result = category.IsAutomatic();
result.Should().Be(expected);
}
#endregion
#region RequiresManualExecution Tests
[Theory]
[InlineData(MigrationCategory.Startup, false)]
[InlineData(MigrationCategory.Seed, false)]
[InlineData(MigrationCategory.Release, true)]
[InlineData(MigrationCategory.Data, true)]
public void RequiresManualExecution_ReturnsExpectedValue(MigrationCategory category, bool expected)
{
var result = category.RequiresManualExecution();
result.Should().Be(expected);
}
#endregion
#region IsAutomatic and RequiresManualExecution are Mutually Exclusive
[Theory]
[InlineData(MigrationCategory.Startup)]
[InlineData(MigrationCategory.Release)]
[InlineData(MigrationCategory.Seed)]
[InlineData(MigrationCategory.Data)]
public void IsAutomatic_And_RequiresManualExecution_AreMutuallyExclusive(MigrationCategory category)
{
var isAutomatic = category.IsAutomatic();
var requiresManual = category.RequiresManualExecution();
// They should be opposite of each other
(isAutomatic ^ requiresManual).Should().BeTrue(
$"Category {category} should be either automatic OR manual, not both or neither");
}
#endregion
#region Real-World Migration Name Tests
[Theory]
[InlineData("001_create_auth_schema.sql", MigrationCategory.Startup)]
[InlineData("002_create_tenants_table.sql", MigrationCategory.Startup)]
[InlineData("003_create_users_table.sql", MigrationCategory.Startup)]
[InlineData("004_add_audit_columns.sql", MigrationCategory.Startup)]
[InlineData("100_drop_legacy_auth_columns.sql", MigrationCategory.Release)]
[InlineData("101_migrate_user_roles.sql", MigrationCategory.Release)]
[InlineData("S001_default_admin_role.sql", MigrationCategory.Seed)]
[InlineData("S002_system_permissions.sql", MigrationCategory.Seed)]
[InlineData("DM001_BackfillTenantIds.sql", MigrationCategory.Data)]
[InlineData("DM002_MigratePasswordHashes.sql", MigrationCategory.Data)]
public void GetCategory_RealWorldMigrationNames_CategorizesCorrectly(
string migrationName,
MigrationCategory expected)
{
var result = MigrationCategoryExtensions.GetCategory(migrationName);
result.Should().Be(expected);
}
#endregion
}

View File

@@ -0,0 +1,548 @@
using System.Reflection;
using FluentAssertions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Migrations;
using Testcontainers.PostgreSql;
namespace StellaOps.Infrastructure.Postgres.Tests.Migrations;
/// <summary>
/// Integration tests for StartupMigrationHost.
/// Uses Testcontainers to spin up a real PostgreSQL instance.
/// </summary>
public sealed class StartupMigrationHostTests : IAsyncLifetime
{
private PostgreSqlContainer? _container;
private string ConnectionString => _container?.GetConnectionString()
?? throw new InvalidOperationException("Container not initialized");
public async Task InitializeAsync()
{
_container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
await _container.StartAsync();
}
public async Task DisposeAsync()
{
if (_container != null)
{
await _container.DisposeAsync();
}
}
#region Migration Execution Tests
[Fact]
public async Task StartAsync_WithPendingStartupMigrations_AppliesThem()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
// FailOnPendingReleaseMigrations = false because test assembly includes release migrations
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
// Act
await host.StartAsync(CancellationToken.None);
// Assert - Check that migrations table has records
var appliedCount = await GetAppliedMigrationCountAsync(schemaName);
appliedCount.Should().BeGreaterThan(0);
// Verify specific startup/seed migrations were applied (not release)
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
migrations.Should().Contain("001_create_test_table.sql");
migrations.Should().Contain("002_add_column.sql");
migrations.Should().Contain("S001_seed_data.sql");
migrations.Should().NotContain("100_release_migration.sql"); // Release not auto-applied
}
[Fact]
public async Task StartAsync_WithAlreadyAppliedMigrations_SkipsThem()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
// First run - apply migrations
var host1 = CreateTestHost(schemaName, options: options);
await host1.StartAsync(CancellationToken.None);
var initialCount = await GetAppliedMigrationCountAsync(schemaName);
// Second run - should skip already applied
var host2 = CreateTestHost(schemaName, options: options);
await host2.StartAsync(CancellationToken.None);
// Assert - count should remain the same
var finalCount = await GetAppliedMigrationCountAsync(schemaName);
finalCount.Should().Be(initialCount);
}
[Fact]
public async Task StartAsync_CreatesSchemaAndMigrationsTable()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
// Act
await host.StartAsync(CancellationToken.None);
// Assert - schema should exist
var schemaExists = await SchemaExistsAsync(schemaName);
schemaExists.Should().BeTrue();
// Assert - migrations table should exist
var tableExists = await TableExistsAsync(schemaName, "schema_migrations");
tableExists.Should().BeTrue();
}
[Fact]
public async Task StartAsync_WithDisabled_SkipsMigrations()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { Enabled = false };
var host = CreateTestHost(schemaName, options: options);
// Act
await host.StartAsync(CancellationToken.None);
// Assert - schema should NOT be created
var schemaExists = await SchemaExistsAsync(schemaName);
schemaExists.Should().BeFalse();
}
#endregion
#region Release Migration Tests
[Fact]
public async Task StartAsync_WithPendingReleaseMigrations_ThrowsAndStopsApplication()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var lifetimeMock = new Mock<IHostApplicationLifetime>();
// Default FailOnPendingReleaseMigrations = true, which should cause failure
// because the test assembly includes 100_release_migration.sql
var host = CreateTestHost(schemaName, lifetime: lifetimeMock.Object);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => host.StartAsync(CancellationToken.None));
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
}
[Fact]
public async Task StartAsync_WithPendingReleaseMigrations_WhenFailOnPendingFalse_DoesNotThrow()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
// Act - should not throw
await host.StartAsync(CancellationToken.None);
// Assert - startup migrations should still be applied
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
migrations.Should().Contain("001_create_test_table.sql");
migrations.Should().NotContain("100_release_migration.sql"); // Release not applied automatically
}
#endregion
#region Checksum Validation Tests
[Fact]
public async Task StartAsync_WithChecksumMismatch_ThrowsAndStopsApplication()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
// First, apply migrations normally
var host1 = CreateTestHost(schemaName, options: options);
await host1.StartAsync(CancellationToken.None);
// Corrupt a checksum in the database
await CorruptChecksumAsync(schemaName, "001_create_test_table.sql");
// Try to run again with checksum validation (and still ignore release migrations)
var lifetimeMock = new Mock<IHostApplicationLifetime>();
var options2 = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host2 = CreateTestHost(schemaName, options: options2, lifetime: lifetimeMock.Object);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => host2.StartAsync(CancellationToken.None));
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
}
[Fact]
public async Task StartAsync_WithChecksumMismatch_WhenFailOnMismatchFalse_DoesNotThrow()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options1 = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
// First, apply migrations normally
var host1 = CreateTestHost(schemaName, options: options1);
await host1.StartAsync(CancellationToken.None);
// Corrupt a checksum
await CorruptChecksumAsync(schemaName, "001_create_test_table.sql");
// Try with checksum mismatch allowed
var options2 = new StartupMigrationOptions
{
FailOnChecksumMismatch = false,
FailOnPendingReleaseMigrations = false
};
var host2 = CreateTestHost(schemaName, options: options2);
// Act - should not throw
await host2.StartAsync(CancellationToken.None);
}
#endregion
#region Advisory Lock Tests
[Fact]
public async Task StartAsync_MultipleConcurrentInstances_OnlyOneRunsMigrations()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var runCount = 0;
var lockObject = new object();
// Create 5 concurrent hosts
var tasks = Enumerable.Range(0, 5)
.Select(_ =>
{
var host = CreateTestHost(
schemaName,
options: new StartupMigrationOptions
{
LockTimeoutSeconds = 30,
FailOnPendingReleaseMigrations = false
});
return host.StartAsync(CancellationToken.None)
.ContinueWith(_ =>
{
lock (lockObject) { runCount++; }
});
})
.ToArray();
// Act
await Task.WhenAll(tasks);
// Assert - all should complete, but migrations applied only once
var migrations = await GetAppliedMigrationNamesAsync(schemaName);
migrations.Should().Contain("001_create_test_table.sql");
// Each migration should only appear once in the table
var counts = await GetMigrationAppliedCountsAsync(schemaName);
foreach (var count in counts.Values)
{
count.Should().Be(1);
}
}
[Fact]
public async Task StartAsync_LockTimeout_ThrowsWhenFailOnLockTimeoutTrue()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions
{
LockTimeoutSeconds = 1,
FailOnLockTimeout = true
};
// Hold the lock manually
await using var lockConn = new NpgsqlConnection(ConnectionString);
await lockConn.OpenAsync();
var lockKey = ComputeLockKey(schemaName);
await using var lockCmd = new NpgsqlCommand(
"SELECT pg_advisory_lock(@key)", lockConn);
lockCmd.Parameters.AddWithValue("key", lockKey);
await lockCmd.ExecuteNonQueryAsync();
try
{
var lifetimeMock = new Mock<IHostApplicationLifetime>();
var host = CreateTestHost(schemaName, options: options, lifetime: lifetimeMock.Object);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => host.StartAsync(CancellationToken.None));
lifetimeMock.Verify(l => l.StopApplication(), Times.Once);
}
finally
{
// Release the lock
await using var unlockCmd = new NpgsqlCommand(
"SELECT pg_advisory_unlock(@key)", lockConn);
unlockCmd.Parameters.AddWithValue("key", lockKey);
await unlockCmd.ExecuteNonQueryAsync();
}
}
[Fact]
public async Task StartAsync_LockTimeout_DoesNotThrowWhenFailOnLockTimeoutFalse()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions
{
LockTimeoutSeconds = 1,
FailOnLockTimeout = false
};
// Hold the lock manually
await using var lockConn = new NpgsqlConnection(ConnectionString);
await lockConn.OpenAsync();
var lockKey = ComputeLockKey(schemaName);
await using var lockCmd = new NpgsqlCommand(
"SELECT pg_advisory_lock(@key)", lockConn);
lockCmd.Parameters.AddWithValue("key", lockKey);
await lockCmd.ExecuteNonQueryAsync();
try
{
var host = CreateTestHost(schemaName, options: options);
// Act - should not throw, just skip migrations
await host.StartAsync(CancellationToken.None);
// Assert - schema should NOT be created since lock wasn't acquired
var schemaExists = await SchemaExistsAsync(schemaName);
schemaExists.Should().BeFalse();
}
finally
{
// Release the lock
await using var unlockCmd = new NpgsqlCommand(
"SELECT pg_advisory_unlock(@key)", lockConn);
unlockCmd.Parameters.AddWithValue("key", lockKey);
await unlockCmd.ExecuteNonQueryAsync();
}
}
#endregion
#region Migration Recording Tests
[Fact]
public async Task StartAsync_RecordsMigrationMetadata()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
// Act
await host.StartAsync(CancellationToken.None);
// Assert - check metadata is recorded
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT category, checksum, duration_ms, applied_by FROM {schemaName}.schema_migrations WHERE migration_name = '001_create_test_table.sql'",
conn);
await using var reader = await cmd.ExecuteReaderAsync();
reader.Read().Should().BeTrue();
reader.GetString(0).Should().Be("startup"); // category
reader.GetString(1).Should().NotBeNullOrEmpty(); // checksum
reader.GetInt32(2).Should().BeGreaterOrEqualTo(0); // duration_ms
reader.GetString(3).Should().NotBeNullOrEmpty(); // applied_by
}
[Fact]
public async Task StartAsync_SeedMigrations_RecordedAsSeedCategory()
{
// Arrange
var schemaName = $"test_{Guid.NewGuid():N}"[..20];
var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false };
var host = CreateTestHost(schemaName, options: options);
// Act
await host.StartAsync(CancellationToken.None);
// Assert
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT category FROM {schemaName}.schema_migrations WHERE migration_name = 'S001_seed_data.sql'",
conn);
var category = await cmd.ExecuteScalarAsync();
category.Should().Be("seed");
}
#endregion
#region Helper Methods
private TestMigrationHost CreateTestHost(
string schemaName,
StartupMigrationOptions? options = null,
IHostApplicationLifetime? lifetime = null)
{
return new TestMigrationHost(
connectionString: ConnectionString,
schemaName: schemaName,
moduleName: "Test",
migrationsAssembly: typeof(StartupMigrationHostTests).Assembly,
logger: NullLogger<TestMigrationHost>.Instance,
lifetime: lifetime ?? CreateMockLifetime(),
options: options);
}
private static IHostApplicationLifetime CreateMockLifetime()
{
var mock = new Mock<IHostApplicationLifetime>();
return mock.Object;
}
private async Task<int> GetAppliedMigrationCountAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT COUNT(*) FROM {schemaName}.schema_migrations",
conn);
var result = await cmd.ExecuteScalarAsync();
return Convert.ToInt32(result);
}
private async Task<List<string>> GetAppliedMigrationNamesAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT migration_name FROM {schemaName}.schema_migrations ORDER BY migration_name",
conn);
var names = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
names.Add(reader.GetString(0));
}
return names;
}
private async Task<Dictionary<string, int>> GetMigrationAppliedCountsAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"SELECT migration_name, COUNT(*) FROM {schemaName}.schema_migrations GROUP BY migration_name",
conn);
var counts = new Dictionary<string, int>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
counts[reader.GetString(0)] = Convert.ToInt32(reader.GetInt64(1));
}
return counts;
}
private async Task<bool> SchemaExistsAsync(string schemaName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = @name)",
conn);
cmd.Parameters.AddWithValue("name", schemaName);
var result = await cmd.ExecuteScalarAsync();
return result is true;
}
private async Task<bool> TableExistsAsync(string schemaName, string tableName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = @schema AND table_name = @table)",
conn);
cmd.Parameters.AddWithValue("schema", schemaName);
cmd.Parameters.AddWithValue("table", tableName);
var result = await cmd.ExecuteScalarAsync();
return result is true;
}
private async Task CorruptChecksumAsync(string schemaName, string migrationName)
{
await using var conn = new NpgsqlConnection(ConnectionString);
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(
$"UPDATE {schemaName}.schema_migrations SET checksum = 'corrupted_checksum' WHERE migration_name = @name",
conn);
cmd.Parameters.AddWithValue("name", migrationName);
await cmd.ExecuteNonQueryAsync();
}
private static long ComputeLockKey(string schemaName)
{
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(schemaName));
return BitConverter.ToInt64(hash, 0);
}
#endregion
}
/// <summary>
/// Concrete test implementation of StartupMigrationHost.
/// Uses embedded resources from the test assembly.
/// </summary>
internal sealed class TestMigrationHost : StartupMigrationHost
{
public TestMigrationHost(
string connectionString,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
ILogger logger,
IHostApplicationLifetime lifetime,
StartupMigrationOptions? options)
: base(connectionString, schemaName, moduleName, migrationsAssembly, logger, lifetime, options)
{
}
}

View File

@@ -0,0 +1,9 @@
-- Migration: 001_create_test_table
-- Category: startup
-- Description: Create initial test table
CREATE TABLE IF NOT EXISTS test_table (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,5 @@
-- Migration: 002_add_column
-- Category: startup
-- Description: Add description column to test table
ALTER TABLE test_table ADD COLUMN IF NOT EXISTS description TEXT;

View File

@@ -0,0 +1,5 @@
-- Migration: 100_release_migration
-- Category: release
-- Description: A release migration that requires manual execution
ALTER TABLE test_table DROP COLUMN IF EXISTS deprecated_column;

View File

@@ -0,0 +1,7 @@
-- Migration: S001_seed_data
-- Category: seed
-- Description: Insert seed data
INSERT INTO test_table (name, description)
VALUES ('seed1', 'First seed record')
ON CONFLICT DO NOTHING;