// ----------------------------------------------------------------------------- // ScanResultIdempotencyTests.cs // Sprint: SPRINT_5100_0009_0001_scanner_tests // Task: SCANNER-5100-014 // Description: Model S1 idempotency tests for Scanner scan results storage // ----------------------------------------------------------------------------- using Dapper; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Infrastructure.Postgres.Options; using StellaOps.Scanner.Storage; using StellaOps.Scanner.Storage.Entities; using StellaOps.Scanner.Storage.Postgres; using StellaOps.TestKit; using Xunit; namespace StellaOps.Scanner.Storage.Tests; /// /// Idempotency tests for scan result storage operations. /// Implements Model S1 (Storage/Postgres) test requirements: /// - Insert same entity twice → no duplicates /// - Same manifest hash → same record returned /// - Update operations are idempotent /// [Collection("scanner-postgres")] [Trait("Category", TestCategories.Integration)] [Trait("Category", TestCategories.StorageIdempotency)] public sealed class ScanResultIdempotencyTests : IAsyncLifetime { private readonly ScannerPostgresFixture _fixture; private PostgresScanManifestRepository _manifestRepository = null!; private ScannerDataSource _dataSource = null!; public ScanResultIdempotencyTests(ScannerPostgresFixture fixture) { _fixture = fixture; } public async ValueTask InitializeAsync() { await _fixture.TruncateAllTablesAsync(); var options = new ScannerStorageOptions { Postgres = new PostgresOptions { ConnectionString = _fixture.ConnectionString, SchemaName = _fixture.SchemaName } }; _dataSource = new ScannerDataSource(Options.Create(options), NullLogger.Instance); _manifestRepository = new PostgresScanManifestRepository(_dataSource); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; [Trait("Category", TestCategories.Unit)] [Fact] public async Task SaveAsync_SameManifestHash_Twice_CanRetrieveByHash() { // Arrange var manifest1 = CreateManifest("sha256:manifest1"); var manifest2 = CreateManifest("sha256:manifest1"); // Same hash await EnsureScanAsync(manifest1.ScanId); await EnsureScanAsync(manifest2.ScanId); // Act var saved1 = await _manifestRepository.SaveAsync(manifest1); // Try to save second with same hash - depending on DB constraint // this might fail or create a new record try { var saved2 = await _manifestRepository.SaveAsync(manifest2); // If it succeeds, verify we can get by hash var retrieved = await _manifestRepository.GetByHashAsync("sha256:manifest1"); retrieved.Should().NotBeNull(); } catch (Npgsql.PostgresException) { // Expected if manifest_hash has unique constraint // Verify the first one still exists var retrieved = await _manifestRepository.GetByHashAsync("sha256:manifest1"); retrieved.Should().NotBeNull(); retrieved!.ManifestId.Should().Be(saved1.ManifestId); } } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetByHashAsync_SameHash_ReturnsConsistentResult() { // Arrange var manifest = CreateManifest("sha256:consistent"); await EnsureScanAsync(manifest.ScanId); await _manifestRepository.SaveAsync(manifest); // Act - Query same hash multiple times var results = new List(); for (int i = 0; i < 10; i++) { results.Add(await _manifestRepository.GetByHashAsync("sha256:consistent")); } // Assert - All should return same record results.Should().AllSatisfy(r => { r.Should().NotBeNull(); r!.ManifestHash.Should().Be("sha256:consistent"); }); var distinctIds = results.Where(r => r != null).Select(r => r!.ManifestId).Distinct().ToList(); distinctIds.Should().HaveCount(1, "same hash should always return same manifest"); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task GetByScanIdAsync_SameId_ReturnsConsistentResult() { // Arrange var scanId = Guid.NewGuid(); var manifest = CreateManifest("sha256:byscan", scanId); await EnsureScanAsync(scanId); await _manifestRepository.SaveAsync(manifest); // Act - Query same scan ID multiple times var results = new List(); for (int i = 0; i < 10; i++) { results.Add(await _manifestRepository.GetByScanIdAsync(scanId)); } // Assert - All should return same record results.Should().AllSatisfy(r => { r.Should().NotBeNull(); r!.ScanId.Should().Be(scanId); }); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task MarkCompletedAsync_Twice_IsIdempotent() { // Arrange var manifest = CreateManifest("sha256:complete"); await EnsureScanAsync(manifest.ScanId); var saved = await _manifestRepository.SaveAsync(manifest); var completedAt1 = DateTimeOffset.UtcNow; var completedAt2 = DateTimeOffset.UtcNow.AddMinutes(1); // Act - Mark completed twice await _manifestRepository.MarkCompletedAsync(saved.ManifestId, completedAt1); var after1 = await _manifestRepository.GetByHashAsync("sha256:complete"); await _manifestRepository.MarkCompletedAsync(saved.ManifestId, completedAt2); var after2 = await _manifestRepository.GetByHashAsync("sha256:complete"); // Assert - Both should succeed, second updates the timestamp after1.Should().NotBeNull(); after1!.ScanCompletedAt.Should().NotBeNull(); after2.Should().NotBeNull(); after2!.ScanCompletedAt.Should().NotBeNull(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task MarkCompletedAsync_NonExistent_DoesNotThrow() { // Arrange var nonExistentId = Guid.NewGuid(); // Act var action = async () => await _manifestRepository.MarkCompletedAsync(nonExistentId, DateTimeOffset.UtcNow); // Assert - Should not throw (0 rows affected is OK) await action.Should().NotThrowAsync(); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task SaveAsync_MultipleDifferentScans_AllPersisted() { // Arrange var manifests = Enumerable.Range(0, 5) .Select(i => CreateManifest($"sha256:multi{i}")) .ToList(); // Act foreach (var manifest in manifests) { await EnsureScanAsync(manifest.ScanId); await _manifestRepository.SaveAsync(manifest); } // Assert - All should be retrievable for (int i = 0; i < 5; i++) { var retrieved = await _manifestRepository.GetByHashAsync($"sha256:multi{i}"); retrieved.Should().NotBeNull(); } } [Trait("Category", TestCategories.Unit)] [Fact] public async Task SaveAsync_MultipleManifstsForSameScan_AllRetrievable() { // Arrange - Same scan ID, different manifests (e.g., scan retry) var scanId = Guid.NewGuid(); var manifest1 = CreateManifest("sha256:retry1", scanId); var manifest2 = CreateManifest("sha256:retry2", scanId); await EnsureScanAsync(scanId); // Act await _manifestRepository.SaveAsync(manifest1); await _manifestRepository.SaveAsync(manifest2); // Assert - GetByScanId returns most recent var retrieved = await _manifestRepository.GetByScanIdAsync(scanId); retrieved.Should().NotBeNull(); // Should return one of them (most recent by created_at) } private static ScanManifestRow CreateManifest(string hash, Guid? scanId = null) => new() { ScanId = scanId ?? Guid.NewGuid(), ManifestHash = hash, SbomHash = "sha256:sbom" + Guid.NewGuid().ToString("N")[..8], RulesHash = "sha256:rules" + Guid.NewGuid().ToString("N")[..8], FeedHash = "sha256:feed" + Guid.NewGuid().ToString("N")[..8], PolicyHash = "sha256:policy" + Guid.NewGuid().ToString("N")[..8], ScanStartedAt = DateTimeOffset.UtcNow, ManifestContent = """{"version": "1.0", "scanner": "stellaops"}""", ScannerVersion = "1.0.0" }; private async Task EnsureScanAsync(Guid scanId) { var schemaName = _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema; var sql = $""" INSERT INTO {schemaName}.scans (scan_id) VALUES (@ScanId) ON CONFLICT DO NOTHING """; await using var connection = await _dataSource.OpenSystemConnectionAsync(); await connection.ExecuteAsync(sql, new { ScanId = scanId }); } }