Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Storage.Tests/ScanResultIdempotencyTests.cs
StellaOps Bot 83c37243e0 save progress
2026-01-03 11:02:24 +02:00

261 lines
9.1 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// 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
/// </summary>
[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<ScannerDataSource>.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<ScanManifestRow?>();
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<ScanManifestRow?>();
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 });
}
}