Add integration tests for migration categories and execution
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
- Implemented MigrationCategoryTests to validate migration categorization for startup, release, seed, and data migrations. - Added tests for edge cases, including null, empty, and whitespace migration names. - Created StartupMigrationHostTests to verify the behavior of the migration host with real PostgreSQL instances using Testcontainers. - Included tests for migration execution, schema creation, and handling of pending release migrations. - Added SQL migration files for testing: creating a test table, adding a column, a release migration, and seeding data.
This commit is contained in:
@@ -9,7 +9,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user