261 lines
9.1 KiB
C#
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 });
|
|
}
|
|
}
|
|
|
|
|
|
|