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

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

View File

@@ -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
}
}