consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Excititor Persistence Tests Agent Charter
|
||||
|
||||
## Mission
|
||||
Validate Excititor PostgreSQL persistence stores and migrations with deterministic fixtures.
|
||||
|
||||
## Responsibilities
|
||||
- Cover repository CRUD, ordering, and idempotency behavior.
|
||||
- Cover migration application and schema invariants.
|
||||
- Keep fixtures deterministic (no random/time unless fixed).
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/excititor/architecture.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Definition of Done
|
||||
- Tests cover success and failure paths for stores and migrations.
|
||||
- Fixtures are deterministic and offline-friendly.
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to DOING/DONE in the sprint file and local TASKS.md.
|
||||
- 2. Review this charter and required docs before coding.
|
||||
- 3. Keep outputs deterministic (ordering, timestamps, hashes) and offline-friendly.
|
||||
- 4. Add tests for negative/error paths.
|
||||
- 5. Revert to TODO if paused; capture context in PR notes.
|
||||
@@ -0,0 +1,301 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExcititorMigrationTests.cs
|
||||
// Sprint: SPRINT_5100_0009_0003_excititor_tests
|
||||
// Task: EXCITITOR-5100-012
|
||||
// Description: Model S1 migration tests for Excititor.Storage
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Reflection;
|
||||
using Dapper;
|
||||
using FluentAssertions;
|
||||
using Npgsql;
|
||||
using StellaOps.Excititor.Persistence.Postgres;
|
||||
using StellaOps.TestKit;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Migration tests for Excititor.Storage.
|
||||
/// Implements Model S1 (Storage/Postgres) migration test requirements:
|
||||
/// - Apply all migrations from scratch (fresh database)
|
||||
/// - Apply migrations from N-1 (incremental application)
|
||||
/// - Verify migration idempotency (apply twice → no error)
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Trait("Category", "StorageMigration")]
|
||||
public sealed class ExcititorMigrationTests : IAsyncLifetime
|
||||
{
|
||||
private PostgreSqlContainer _container = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_container = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:16-alpine")
|
||||
.WithDatabase("excititor_migration_test")
|
||||
.WithUsername("postgres")
|
||||
.WithPassword("postgres")
|
||||
.Build();
|
||||
|
||||
await _container.StartAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyMigrations_FromScratch_AllTablesCreated()
|
||||
{
|
||||
// Arrange
|
||||
var connectionString = _container.GetConnectionString();
|
||||
|
||||
// Act - Apply all migrations from scratch
|
||||
await ApplyAllMigrationsAsync(connectionString);
|
||||
|
||||
// Assert - Verify Excititor tables exist
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
var tables = await connection.QueryAsync<string>(
|
||||
@"SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name");
|
||||
|
||||
var tableList = tables.ToList();
|
||||
|
||||
// Verify migration tracking table exists
|
||||
tableList.Should().Contain("__migrations", "Migration tracking table should exist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyMigrations_FromScratch_AllMigrationsRecorded()
|
||||
{
|
||||
// Arrange
|
||||
var connectionString = _container.GetConnectionString();
|
||||
await ApplyAllMigrationsAsync(connectionString);
|
||||
|
||||
// Assert - Verify migrations are recorded
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
var migrationsApplied = await connection.QueryAsync<string>(
|
||||
"SELECT migration_id FROM __migrations ORDER BY applied_at");
|
||||
|
||||
var migrationList = migrationsApplied.ToList();
|
||||
migrationList.Should().NotBeEmpty("migrations should be tracked");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyMigrations_Twice_IsIdempotent()
|
||||
{
|
||||
// Arrange
|
||||
var connectionString = _container.GetConnectionString();
|
||||
|
||||
// Act - Apply migrations twice
|
||||
await ApplyAllMigrationsAsync(connectionString);
|
||||
var applyAgain = async () => await ApplyAllMigrationsAsync(connectionString);
|
||||
|
||||
// Assert - Second application should not throw
|
||||
await applyAgain.Should().NotThrowAsync(
|
||||
"applying migrations twice should be idempotent");
|
||||
|
||||
// Verify migrations are not duplicated
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
var migrationCount = await connection.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM __migrations");
|
||||
|
||||
// Count unique migrations
|
||||
var uniqueMigrations = await connection.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(DISTINCT migration_id) FROM __migrations");
|
||||
|
||||
migrationCount.Should().Be(uniqueMigrations,
|
||||
"each migration should only be recorded once");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyMigrations_VerifySchemaIntegrity()
|
||||
{
|
||||
// Arrange
|
||||
var connectionString = _container.GetConnectionString();
|
||||
await ApplyAllMigrationsAsync(connectionString);
|
||||
|
||||
// Assert - Verify indexes exist
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
var indexes = await connection.QueryAsync<string>(
|
||||
@"SELECT indexname FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
ORDER BY indexname");
|
||||
|
||||
var indexList = indexes.ToList();
|
||||
indexList.Should().NotBeNull("indexes collection should exist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyMigrations_IndividualMigrationsCanRollForward()
|
||||
{
|
||||
// Arrange
|
||||
var connectionString = _container.GetConnectionString();
|
||||
|
||||
// Act - Apply migrations in sequence
|
||||
var migrationFiles = GetMigrationFiles();
|
||||
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
// Create migration tracking table first
|
||||
await connection.ExecuteAsync(@"
|
||||
CREATE TABLE IF NOT EXISTS __migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
migration_id TEXT NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)");
|
||||
|
||||
// Apply each migration in order
|
||||
int appliedCount = 0;
|
||||
foreach (var migrationFile in migrationFiles.OrderBy(f => f))
|
||||
{
|
||||
var migrationId = Path.GetFileName(migrationFile);
|
||||
|
||||
// Check if already applied
|
||||
var alreadyApplied = await connection.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM __migrations WHERE migration_id = @Id",
|
||||
new { Id = migrationId });
|
||||
|
||||
if (alreadyApplied > 0)
|
||||
continue;
|
||||
|
||||
// Apply migration
|
||||
var sql = GetMigrationContent(migrationFile);
|
||||
if (!string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
await connection.ExecuteAsync(sql);
|
||||
await connection.ExecuteAsync(
|
||||
"INSERT INTO __migrations (migration_id) VALUES (@Id)",
|
||||
new { Id = migrationId });
|
||||
appliedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Assert - Migrations should be applied (if any exist)
|
||||
var totalMigrations = await connection.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM __migrations");
|
||||
|
||||
totalMigrations.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyMigrations_ForeignKeyConstraintsValid()
|
||||
{
|
||||
// Arrange
|
||||
var connectionString = _container.GetConnectionString();
|
||||
await ApplyAllMigrationsAsync(connectionString);
|
||||
|
||||
// Assert - Verify foreign key constraints exist and are valid
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
var foreignKeys = await connection.QueryAsync<string>(
|
||||
@"SELECT tc.constraint_name
|
||||
FROM information_schema.table_constraints tc
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND tc.table_schema = 'public'
|
||||
ORDER BY tc.constraint_name");
|
||||
|
||||
var fkList = foreignKeys.ToList();
|
||||
// Foreign keys may or may not exist depending on schema design
|
||||
fkList.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyMigrations_VexTablesHaveCorrectSchema()
|
||||
{
|
||||
// Arrange
|
||||
var connectionString = _container.GetConnectionString();
|
||||
await ApplyAllMigrationsAsync(connectionString);
|
||||
|
||||
// Assert - Check for VEX-related tables if they exist
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
var tables = await connection.QueryAsync<string>(
|
||||
@"SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name LIKE '%vex%' OR table_name LIKE '%linkset%'
|
||||
ORDER BY table_name");
|
||||
|
||||
var tableList = tables.ToList();
|
||||
// VEX tables may or may not exist depending on migration state
|
||||
tableList.Should().NotBeNull();
|
||||
}
|
||||
|
||||
private async Task ApplyAllMigrationsAsync(string connectionString)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
|
||||
// Create migration tracking table
|
||||
await connection.ExecuteAsync(@"
|
||||
CREATE TABLE IF NOT EXISTS __migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
migration_id TEXT NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)");
|
||||
|
||||
// Get and apply all migrations
|
||||
var migrationFiles = GetMigrationFiles();
|
||||
|
||||
foreach (var migrationFile in migrationFiles.OrderBy(f => f))
|
||||
{
|
||||
var migrationId = Path.GetFileName(migrationFile);
|
||||
|
||||
// Skip if already applied
|
||||
var alreadyApplied = await connection.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(*) FROM __migrations WHERE migration_id = @Id",
|
||||
new { Id = migrationId });
|
||||
|
||||
if (alreadyApplied > 0)
|
||||
continue;
|
||||
|
||||
// Apply migration
|
||||
var sql = GetMigrationContent(migrationFile);
|
||||
if (!string.IsNullOrWhiteSpace(sql))
|
||||
{
|
||||
await connection.ExecuteAsync(sql);
|
||||
await connection.ExecuteAsync(
|
||||
"INSERT INTO __migrations (migration_id) VALUES (@Id)",
|
||||
new { Id = migrationId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetMigrationFiles()
|
||||
{
|
||||
var assembly = typeof(ExcititorDataSource).Assembly;
|
||||
var resourceNames = assembly.GetManifestResourceNames()
|
||||
.Where(n => n.Contains("Migrations") && n.EndsWith(".sql"))
|
||||
.OrderBy(n => n);
|
||||
|
||||
return resourceNames;
|
||||
}
|
||||
|
||||
private static string GetMigrationContent(string resourceName)
|
||||
{
|
||||
var assembly = typeof(ExcititorDataSource).Assembly;
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream == null)
|
||||
return string.Empty;
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExcititorPostgresFixture.cs
|
||||
// Sprint: SPRINT_5100_0007_0004_storage_harness
|
||||
// Task: STOR-HARNESS-012
|
||||
// Description: Excititor PostgreSQL test fixture using TestKit
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Reflection;
|
||||
using StellaOps.Excititor.Persistence.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using Xunit;
|
||||
|
||||
// Type aliases to disambiguate TestKit and Infrastructure.Postgres.Testing fixtures
|
||||
using TestKitPostgresFixture = StellaOps.TestKit.Fixtures.PostgresFixture;
|
||||
using TestKitPostgresIsolationMode = StellaOps.TestKit.Fixtures.PostgresIsolationMode;
|
||||
|
||||
namespace StellaOps.Excititor.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for the Excititor module.
|
||||
/// Runs migrations from embedded resources and provides test isolation.
|
||||
/// </summary>
|
||||
public sealed class ExcititorPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<ExcititorPostgresFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(ExcititorDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "Excititor";
|
||||
|
||||
protected override string? GetResourcePrefix() => null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Excititor PostgreSQL integration tests.
|
||||
/// Tests in this collection share a single PostgreSQL container instance.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class ExcititorPostgresCollection : ICollectionFixture<ExcititorPostgresFixture>
|
||||
{
|
||||
public const string Name = "ExcititorPostgres";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TestKit-based PostgreSQL fixture for Excititor storage tests.
|
||||
/// Uses TestKit's PostgresFixture for enhanced isolation modes.
|
||||
/// </summary>
|
||||
public sealed class ExcititorTestKitPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private TestKitPostgresFixture _fixture = null!;
|
||||
private Assembly MigrationAssembly => typeof(ExcititorDataSource).Assembly;
|
||||
|
||||
public TestKitPostgresFixture Fixture => _fixture;
|
||||
public string ConnectionString => _fixture.ConnectionString;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_fixture = new TestKitPostgresFixture();
|
||||
await _fixture.InitializeAsync();
|
||||
await _fixture.ApplyMigrationsFromAssemblyAsync(MigrationAssembly, "public", "Migrations");
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => _fixture.DisposeAsync();
|
||||
|
||||
public Task TruncateAllTablesAsync() => _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Excititor TestKit PostgreSQL tests.
|
||||
/// </summary>
|
||||
[CollectionDefinition(ExcititorTestKitPostgresCollection.Name)]
|
||||
public sealed class ExcititorTestKitPostgresCollection : ICollectionFixture<ExcititorTestKitPostgresFixture>
|
||||
{
|
||||
public const string Name = "ExcititorTestKitPostgres";
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.Persistence.Postgres;
|
||||
using StellaOps.Excititor.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Excititor.Persistence.Tests;
|
||||
|
||||
[Collection(ExcititorPostgresCollection.Name)]
|
||||
public sealed class PostgresAppendOnlyLinksetStoreTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ExcititorPostgresFixture _fixture;
|
||||
private readonly PostgresAppendOnlyLinksetStore _store;
|
||||
private readonly ExcititorDataSource _dataSource;
|
||||
|
||||
public PostgresAppendOnlyLinksetStoreTests(ExcititorPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
var options = Options.Create(new PostgresOptions
|
||||
{
|
||||
ConnectionString = fixture.ConnectionString,
|
||||
SchemaName = fixture.SchemaName,
|
||||
AutoMigrate = false
|
||||
});
|
||||
|
||||
_dataSource = new ExcititorDataSource(options, NullLogger<ExcititorDataSource>.Instance);
|
||||
_store = new PostgresAppendOnlyLinksetStore(_dataSource, NullLogger<PostgresAppendOnlyLinksetStore>.Instance);
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task AppendObservation_CreatesLinksetAndDedupes()
|
||||
{
|
||||
var tenant = "tenant-a";
|
||||
var vuln = "CVE-2025-1234";
|
||||
var product = "pkg:nuget/demo@1.0.0";
|
||||
var scope = VexProductScope.Unknown(product);
|
||||
var observation = new VexLinksetObservationRefModel("obs-1", "provider-a", "not_affected", 0.9);
|
||||
|
||||
var first = await _store.AppendObservationAsync(tenant, vuln, product, observation, scope, CancellationToken.None);
|
||||
|
||||
first.WasCreated.Should().BeTrue();
|
||||
first.ObservationsAdded.Should().Be(1);
|
||||
first.SequenceNumber.Should().BeGreaterThan(0);
|
||||
first.Linkset.Observations.Should().HaveCount(1);
|
||||
|
||||
var second = await _store.AppendObservationAsync(tenant, vuln, product, observation, scope, CancellationToken.None);
|
||||
|
||||
second.HadChanges.Should().BeFalse();
|
||||
second.Linkset.Observations.Should().HaveCount(1);
|
||||
second.SequenceNumber.Should().Be(first.SequenceNumber);
|
||||
|
||||
var mutations = await _store.GetMutationLogAsync(tenant, first.Linkset.LinksetId, CancellationToken.None);
|
||||
mutations.Select(m => m.SequenceNumber).Should().BeInAscendingOrder();
|
||||
mutations.Should().HaveCount(2); // created + observation
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task AppendBatch_AppendsMultipleAndMaintainsOrder()
|
||||
{
|
||||
var tenant = "tenant-b";
|
||||
var vuln = "CVE-2025-2000";
|
||||
var product = "pkg:maven/demo/demo@2.0.0";
|
||||
var scope = VexProductScope.Unknown(product);
|
||||
var observations = new[]
|
||||
{
|
||||
new VexLinksetObservationRefModel("obs-2", "provider-b", "affected", 0.7),
|
||||
new VexLinksetObservationRefModel("obs-1", "provider-a", "affected", 0.8),
|
||||
new VexLinksetObservationRefModel("obs-3", "provider-a", "fixed", 0.9)
|
||||
};
|
||||
|
||||
var result = await _store.AppendObservationsBatchAsync(tenant, vuln, product, observations, scope, CancellationToken.None);
|
||||
|
||||
result.Linkset.Observations.Should().HaveCount(3);
|
||||
result.Linkset.Observations
|
||||
.Select(o => $"{o.ProviderId}:{o.Status}:{o.ObservationId}")
|
||||
.Should()
|
||||
.ContainInOrder(
|
||||
"provider-a:affected:obs-1",
|
||||
"provider-a:fixed:obs-3",
|
||||
"provider-b:affected:obs-2");
|
||||
result.SequenceNumber.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task AppendDisagreement_RegistersConflictAndCounts()
|
||||
{
|
||||
var tenant = "tenant-c";
|
||||
var vuln = "CVE-2025-3000";
|
||||
var product = "pkg:deb/debian/demo@1.2.3";
|
||||
var disagreement = new VexObservationDisagreement("provider-c", "not_affected", "component_not_present", 0.6);
|
||||
|
||||
var result = await _store.AppendDisagreementAsync(tenant, vuln, product, disagreement, CancellationToken.None);
|
||||
|
||||
result.Linkset.HasConflicts.Should().BeTrue();
|
||||
result.SequenceNumber.Should().BeGreaterThan(0);
|
||||
|
||||
var conflicts = await _store.FindWithConflictsAsync(tenant, limit: 10, CancellationToken.None);
|
||||
conflicts.Should().ContainSingle(ls => ls.LinksetId == result.Linkset.LinksetId);
|
||||
|
||||
var conflictCount = await _store.CountWithConflictsAsync(tenant, CancellationToken.None);
|
||||
conflictCount.Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core.Evidence;
|
||||
using StellaOps.Excititor.Persistence.Postgres;
|
||||
using StellaOps.Excititor.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Excititor.Persistence.Tests;
|
||||
|
||||
[Collection(ExcititorPostgresCollection.Name)]
|
||||
public sealed class PostgresVexAttestationStoreTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ExcititorPostgresFixture _fixture;
|
||||
private readonly PostgresVexAttestationStore _store;
|
||||
private readonly ExcititorDataSource _dataSource;
|
||||
private readonly string _tenantId = "tenant-" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
public PostgresVexAttestationStoreTests(ExcititorPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
var options = Options.Create(new PostgresOptions
|
||||
{
|
||||
ConnectionString = fixture.ConnectionString,
|
||||
SchemaName = fixture.SchemaName,
|
||||
AutoMigrate = false
|
||||
});
|
||||
|
||||
_dataSource = new ExcititorDataSource(options, NullLogger<ExcititorDataSource>.Instance);
|
||||
_store = new PostgresVexAttestationStore(_dataSource, NullLogger<PostgresVexAttestationStore>.Instance);
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.Fixture.RunMigrationsFromAssemblyAsync(
|
||||
typeof(ExcititorDataSource).Assembly,
|
||||
moduleName: "Excititor",
|
||||
resourcePrefix: "Migrations",
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SaveAndFindById_RoundTripsAttestation()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateAttestation("attest-1", "manifest-1");
|
||||
|
||||
// Act
|
||||
await _store.SaveAsync(attestation, CancellationToken.None);
|
||||
var fetched = await _store.FindByIdAsync(_tenantId, "attest-1", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.AttestationId.Should().Be("attest-1");
|
||||
fetched.ManifestId.Should().Be("manifest-1");
|
||||
fetched.MerkleRoot.Should().Be("sha256:merkle123");
|
||||
fetched.DsseEnvelopeHash.Should().Be("sha256:envelope456");
|
||||
fetched.ItemCount.Should().Be(10);
|
||||
fetched.Metadata.Should().ContainKey("source");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FindByIdAsync_ReturnsNullForUnknownId()
|
||||
{
|
||||
// Act
|
||||
var result = await _store.FindByIdAsync(_tenantId, "nonexistent", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FindByManifestIdAsync_ReturnsMatchingAttestation()
|
||||
{
|
||||
// Arrange
|
||||
var attestation = CreateAttestation("attest-2", "manifest-target");
|
||||
await _store.SaveAsync(attestation, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var fetched = await _store.FindByManifestIdAsync(_tenantId, "manifest-target", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.AttestationId.Should().Be("attest-2");
|
||||
fetched.ManifestId.Should().Be("manifest-target");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SaveAsync_UpdatesExistingAttestation()
|
||||
{
|
||||
// Arrange
|
||||
var original = CreateAttestation("attest-update", "manifest-old");
|
||||
var updated = new VexStoredAttestation(
|
||||
"attest-update",
|
||||
_tenantId,
|
||||
"manifest-new",
|
||||
"sha256:newmerkle",
|
||||
"{\"updated\":true}",
|
||||
"sha256:newhash",
|
||||
20,
|
||||
DateTimeOffset.UtcNow,
|
||||
ImmutableDictionary<string, string>.Empty.Add("version", "2"));
|
||||
|
||||
// Act
|
||||
await _store.SaveAsync(original, CancellationToken.None);
|
||||
await _store.SaveAsync(updated, CancellationToken.None);
|
||||
var fetched = await _store.FindByIdAsync(_tenantId, "attest-update", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.ManifestId.Should().Be("manifest-new");
|
||||
fetched.ItemCount.Should().Be(20);
|
||||
fetched.Metadata.Should().ContainKey("version");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CountAsync_ReturnsCorrectCount()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateAttestation("attest-a", "manifest-a"), CancellationToken.None);
|
||||
await _store.SaveAsync(CreateAttestation("attest-b", "manifest-b"), CancellationToken.None);
|
||||
await _store.SaveAsync(CreateAttestation("attest-c", "manifest-c"), CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var count = await _store.CountAsync(_tenantId, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
count.Should().Be(3);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ListAsync_ReturnsPaginatedResults()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var attestation = CreateAttestation($"attest-{i:D2}", $"manifest-{i:D2}");
|
||||
await _store.SaveAsync(attestation, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Act
|
||||
var query = new VexAttestationQuery(_tenantId, limit: 2, offset: 0);
|
||||
var result = await _store.ListAsync(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Items.Should().HaveCount(2);
|
||||
result.TotalCount.Should().Be(5);
|
||||
result.HasMore.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ListAsync_FiltersBySinceAndUntil()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var attestations = new[]
|
||||
{
|
||||
CreateAttestation("old-attest", "manifest-old", now.AddDays(-10)),
|
||||
CreateAttestation("recent-attest", "manifest-recent", now.AddDays(-1)),
|
||||
CreateAttestation("new-attest", "manifest-new", now)
|
||||
};
|
||||
|
||||
foreach (var a in attestations)
|
||||
{
|
||||
await _store.SaveAsync(a, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Act
|
||||
var query = new VexAttestationQuery(_tenantId, since: now.AddDays(-2), until: now.AddDays(1));
|
||||
var result = await _store.ListAsync(query, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Items.Should().HaveCount(2);
|
||||
result.Items.Select(a => a.AttestationId).Should().Contain("recent-attest", "new-attest");
|
||||
}
|
||||
|
||||
private VexStoredAttestation CreateAttestation(string attestationId, string manifestId, DateTimeOffset? attestedAt = null) =>
|
||||
new VexStoredAttestation(
|
||||
attestationId,
|
||||
_tenantId,
|
||||
manifestId,
|
||||
"sha256:merkle123",
|
||||
"{\"payloadType\":\"application/vnd.in-toto+json\"}",
|
||||
"sha256:envelope456",
|
||||
10,
|
||||
attestedAt ?? DateTimeOffset.UtcNow,
|
||||
ImmutableDictionary<string, string>.Empty.Add("source", "test"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.Persistence.Postgres;
|
||||
using StellaOps.Excititor.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Excititor.Persistence.Tests;
|
||||
|
||||
[Collection(ExcititorPostgresCollection.Name)]
|
||||
public sealed class PostgresVexObservationStoreTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ExcititorPostgresFixture _fixture;
|
||||
private readonly PostgresVexObservationStore _store;
|
||||
private readonly ExcititorDataSource _dataSource;
|
||||
private readonly string _tenantId = "tenant-" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
public PostgresVexObservationStoreTests(ExcititorPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
var options = Options.Create(new PostgresOptions
|
||||
{
|
||||
ConnectionString = fixture.ConnectionString,
|
||||
SchemaName = fixture.SchemaName,
|
||||
AutoMigrate = false
|
||||
});
|
||||
|
||||
_dataSource = new ExcititorDataSource(options, NullLogger<ExcititorDataSource>.Instance);
|
||||
_store = new PostgresVexObservationStore(_dataSource, NullLogger<PostgresVexObservationStore>.Instance);
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.Fixture.RunMigrationsFromAssemblyAsync(
|
||||
typeof(ExcititorDataSource).Assembly,
|
||||
moduleName: "Excititor",
|
||||
resourcePrefix: "Migrations",
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InsertAndGetById_RoundTripsObservation()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateObservation("obs-1", "provider-a", "CVE-2025-1234", "pkg:npm/lodash@4.17.21");
|
||||
|
||||
// Act
|
||||
var inserted = await _store.InsertAsync(observation, CancellationToken.None);
|
||||
var fetched = await _store.GetByIdAsync(_tenantId, "obs-1", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
inserted.Should().BeTrue();
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.ObservationId.Should().Be("obs-1");
|
||||
fetched.ProviderId.Should().Be("provider-a");
|
||||
fetched.Statements.Should().HaveCount(1);
|
||||
fetched.Statements[0].VulnerabilityId.Should().Be("CVE-2025-1234");
|
||||
fetched.Statements[0].ProductKey.Should().Be("pkg:npm/lodash@4.17.21");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsNullForUnknownId()
|
||||
{
|
||||
// Act
|
||||
var result = await _store.GetByIdAsync(_tenantId, "nonexistent", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InsertAsync_ReturnsFalseForDuplicateId()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateObservation("obs-dup", "provider-a", "CVE-2025-9999", "pkg:npm/test@1.0.0");
|
||||
|
||||
// Act
|
||||
var first = await _store.InsertAsync(observation, CancellationToken.None);
|
||||
var second = await _store.InsertAsync(observation, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
first.Should().BeTrue();
|
||||
second.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpsertAsync_UpdatesExistingObservation()
|
||||
{
|
||||
// Arrange
|
||||
var original = CreateObservation("obs-upsert", "provider-a", "CVE-2025-0001", "pkg:npm/old@1.0.0");
|
||||
var updated = CreateObservation("obs-upsert", "provider-b", "CVE-2025-0001", "pkg:npm/new@2.0.0");
|
||||
|
||||
// Act
|
||||
await _store.InsertAsync(original, CancellationToken.None);
|
||||
await _store.UpsertAsync(updated, CancellationToken.None);
|
||||
var fetched = await _store.GetByIdAsync(_tenantId, "obs-upsert", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.ProviderId.Should().Be("provider-b");
|
||||
fetched.Statements[0].ProductKey.Should().Be("pkg:npm/new@2.0.0");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FindByProviderAsync_ReturnsMatchingObservations()
|
||||
{
|
||||
// Arrange
|
||||
await _store.InsertAsync(CreateObservation("obs-p1", "redhat-csaf", "CVE-2025-1111", "pkg:rpm/test@1.0"), CancellationToken.None);
|
||||
await _store.InsertAsync(CreateObservation("obs-p2", "redhat-csaf", "CVE-2025-2222", "pkg:rpm/test@2.0"), CancellationToken.None);
|
||||
await _store.InsertAsync(CreateObservation("obs-p3", "ubuntu-csaf", "CVE-2025-3333", "pkg:deb/test@1.0"), CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var found = await _store.FindByProviderAsync(_tenantId, "redhat-csaf", limit: 10, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
found.Should().HaveCount(2);
|
||||
found.Select(o => o.ObservationId).Should().Contain("obs-p1", "obs-p2");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CountAsync_ReturnsCorrectCount()
|
||||
{
|
||||
// Arrange
|
||||
await _store.InsertAsync(CreateObservation("obs-c1", "provider-a", "CVE-1", "pkg:1"), CancellationToken.None);
|
||||
await _store.InsertAsync(CreateObservation("obs-c2", "provider-a", "CVE-2", "pkg:2"), CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var count = await _store.CountAsync(_tenantId, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
count.Should().Be(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DeleteAsync_RemovesObservation()
|
||||
{
|
||||
// Arrange
|
||||
await _store.InsertAsync(CreateObservation("obs-del", "provider-a", "CVE-DEL", "pkg:del"), CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var deleted = await _store.DeleteAsync(_tenantId, "obs-del", CancellationToken.None);
|
||||
var fetched = await _store.GetByIdAsync(_tenantId, "obs-del", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
deleted.Should().BeTrue();
|
||||
fetched.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InsertManyAsync_InsertsMultipleObservations()
|
||||
{
|
||||
// Arrange
|
||||
var observations = new[]
|
||||
{
|
||||
CreateObservation("batch-1", "provider-a", "CVE-B1", "pkg:b1"),
|
||||
CreateObservation("batch-2", "provider-a", "CVE-B2", "pkg:b2"),
|
||||
CreateObservation("batch-3", "provider-a", "CVE-B3", "pkg:b3")
|
||||
};
|
||||
|
||||
// Act
|
||||
var inserted = await _store.InsertManyAsync(_tenantId, observations, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
inserted.Should().Be(3);
|
||||
var count = await _store.CountAsync(_tenantId, CancellationToken.None);
|
||||
count.Should().Be(3);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpdateRekorLinkageAsync_RoundTripsLinkageAndLookupByUuid()
|
||||
{
|
||||
// Arrange
|
||||
var observation = CreateObservation(
|
||||
"obs-rekor-1",
|
||||
"provider-a",
|
||||
"CVE-REKOR-1",
|
||||
"pkg:rekor/one@1.0.0",
|
||||
createdAt: new DateTimeOffset(2026, 1, 17, 8, 0, 0, TimeSpan.Zero));
|
||||
await _store.InsertAsync(observation, CancellationToken.None);
|
||||
|
||||
var linkage = new RekorLinkage
|
||||
{
|
||||
Uuid = "rekor-uuid-obs-rekor-1",
|
||||
LogIndex = 4242,
|
||||
IntegratedTime = new DateTimeOffset(2026, 1, 17, 8, 5, 0, TimeSpan.Zero),
|
||||
LogUrl = "https://rekor.local.test",
|
||||
TreeRoot = "tree-root-001",
|
||||
TreeSize = 9001,
|
||||
EntryBodyHash = "sha256:entry-body-001",
|
||||
EntryKind = "dsse",
|
||||
InclusionProof = new VexInclusionProof
|
||||
{
|
||||
LeafIndex = 4242,
|
||||
TreeSize = 9001,
|
||||
Hashes = ["hash-a", "hash-b"],
|
||||
RootHash = "root-hash-001"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var updated = await _store.UpdateRekorLinkageAsync(
|
||||
_tenantId,
|
||||
"obs-rekor-1",
|
||||
linkage,
|
||||
CancellationToken.None);
|
||||
var fetched = await _store.GetByRekorUuidAsync(
|
||||
_tenantId,
|
||||
"rekor-uuid-obs-rekor-1",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
updated.Should().BeTrue();
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.ObservationId.Should().Be("obs-rekor-1");
|
||||
fetched.HasRekorLinkage.Should().BeTrue();
|
||||
fetched.RekorUuid.Should().Be("rekor-uuid-obs-rekor-1");
|
||||
fetched.RekorLogIndex.Should().Be(4242);
|
||||
fetched.RekorLogUrl.Should().Be("https://rekor.local.test");
|
||||
fetched.RekorIntegratedTime.Should().Be(new DateTimeOffset(2026, 1, 17, 8, 5, 0, TimeSpan.Zero));
|
||||
fetched.RekorInclusionProof.Should().NotBeNull();
|
||||
fetched.RekorInclusionProof!.LeafIndex.Should().Be(4242);
|
||||
fetched.RekorInclusionProof.TreeSize.Should().Be(9001);
|
||||
fetched.RekorInclusionProof.Hashes.Should().Equal("hash-a", "hash-b");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpdateRekorLinkageAsync_ReturnsFalseForUnknownObservation()
|
||||
{
|
||||
// Arrange
|
||||
var linkage = new RekorLinkage
|
||||
{
|
||||
Uuid = "missing-observation-rekor-uuid",
|
||||
LogIndex = 1,
|
||||
IntegratedTime = new DateTimeOffset(2026, 1, 17, 9, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
|
||||
// Act
|
||||
var updated = await _store.UpdateRekorLinkageAsync(
|
||||
_tenantId,
|
||||
"missing-observation-id",
|
||||
linkage,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
updated.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetPendingRekorAttestationAsync_ReturnsOnlyUnlinkedObservationsOrderedByCreatedAt()
|
||||
{
|
||||
// Arrange
|
||||
var start = new DateTimeOffset(2026, 1, 17, 10, 0, 0, TimeSpan.Zero);
|
||||
await _store.InsertAsync(CreateObservation("obs-pending-1", "provider-a", "CVE-PEND-1", "pkg:pending/one@1.0.0", start), CancellationToken.None);
|
||||
await _store.InsertAsync(CreateObservation("obs-pending-2", "provider-a", "CVE-PEND-2", "pkg:pending/two@1.0.0", start.AddMinutes(1)), CancellationToken.None);
|
||||
await _store.InsertAsync(CreateObservation("obs-pending-3", "provider-a", "CVE-PEND-3", "pkg:pending/three@1.0.0", start.AddMinutes(2)), CancellationToken.None);
|
||||
|
||||
var linkage = new RekorLinkage
|
||||
{
|
||||
Uuid = "rekor-uuid-linked-obs-pending-2",
|
||||
LogIndex = 2002,
|
||||
IntegratedTime = start.AddMinutes(3)
|
||||
};
|
||||
await _store.UpdateRekorLinkageAsync(_tenantId, "obs-pending-2", linkage, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var pending = await _store.GetPendingRekorAttestationAsync(_tenantId, 10, CancellationToken.None);
|
||||
var limited = await _store.GetPendingRekorAttestationAsync(_tenantId, 1, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
pending.Select(o => o.ObservationId).Should().Equal("obs-pending-1", "obs-pending-3");
|
||||
pending.Should().OnlyContain(o => !o.HasRekorLinkage);
|
||||
limited.Select(o => o.ObservationId).Should().Equal("obs-pending-1");
|
||||
}
|
||||
|
||||
private VexObservation CreateObservation(
|
||||
string observationId,
|
||||
string providerId,
|
||||
string vulnId,
|
||||
string productKey,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
var now = createdAt ?? DateTimeOffset.UtcNow;
|
||||
|
||||
var statement = new VexObservationStatement(
|
||||
vulnId,
|
||||
productKey,
|
||||
VexClaimStatus.NotAffected,
|
||||
lastObserved: now,
|
||||
purl: productKey,
|
||||
cpe: null,
|
||||
evidence: ImmutableArray<JsonNode>.Empty);
|
||||
|
||||
var upstream = new VexObservationUpstream(
|
||||
upstreamId: observationId,
|
||||
documentVersion: "1.0",
|
||||
fetchedAt: now,
|
||||
receivedAt: now,
|
||||
contentHash: $"sha256:{Guid.NewGuid():N}",
|
||||
signature: new VexObservationSignature(present: false, null, null, null));
|
||||
|
||||
var linkset = new VexObservationLinkset(
|
||||
aliases: [vulnId],
|
||||
purls: [productKey],
|
||||
cpes: [],
|
||||
references: [new VexObservationReference("source", $"https://example.test/{observationId}")]);
|
||||
|
||||
var content = new VexObservationContent(
|
||||
format: "csaf",
|
||||
specVersion: "2.0",
|
||||
raw: JsonNode.Parse("""{"document":"test"}""")!);
|
||||
|
||||
return new VexObservation(
|
||||
observationId,
|
||||
_tenantId,
|
||||
providerId,
|
||||
streamId: "stream-default",
|
||||
upstream,
|
||||
[statement],
|
||||
content,
|
||||
linkset,
|
||||
now,
|
||||
supersedes: null,
|
||||
attributes: ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Persistence.Postgres;
|
||||
using StellaOps.Excititor.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Excititor.Persistence.Tests;
|
||||
|
||||
[Collection(ExcititorPostgresCollection.Name)]
|
||||
public sealed class PostgresVexProviderStoreTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ExcititorPostgresFixture _fixture;
|
||||
private readonly PostgresVexProviderStore _store;
|
||||
private readonly ExcititorDataSource _dataSource;
|
||||
|
||||
public PostgresVexProviderStoreTests(ExcititorPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
var options = Options.Create(new PostgresOptions
|
||||
{
|
||||
ConnectionString = fixture.ConnectionString,
|
||||
SchemaName = fixture.SchemaName,
|
||||
AutoMigrate = false
|
||||
});
|
||||
|
||||
_dataSource = new ExcititorDataSource(options, NullLogger<ExcititorDataSource>.Instance);
|
||||
_store = new PostgresVexProviderStore(_dataSource, NullLogger<PostgresVexProviderStore>.Instance);
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.Fixture.RunMigrationsFromAssemblyAsync(
|
||||
typeof(ExcititorDataSource).Assembly,
|
||||
moduleName: "Excititor",
|
||||
resourcePrefix: "Migrations",
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SaveAndFind_RoundTripsProvider()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new VexProvider(
|
||||
id: "redhat-csaf",
|
||||
displayName: "Red Hat CSAF",
|
||||
kind: VexProviderKind.Vendor,
|
||||
baseUris: [new Uri("https://access.redhat.com/security/data/csaf/")],
|
||||
discovery: new VexProviderDiscovery(
|
||||
new Uri("https://access.redhat.com/security/data/csaf/.well-known/csaf/provider-metadata.json"),
|
||||
null),
|
||||
trust: VexProviderTrust.Default,
|
||||
enabled: true);
|
||||
|
||||
// Act
|
||||
await _store.SaveAsync(provider, CancellationToken.None);
|
||||
var fetched = await _store.FindAsync("redhat-csaf", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Id.Should().Be("redhat-csaf");
|
||||
fetched.DisplayName.Should().Be("Red Hat CSAF");
|
||||
fetched.Kind.Should().Be(VexProviderKind.Vendor);
|
||||
fetched.Enabled.Should().BeTrue();
|
||||
fetched.BaseUris.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FindAsync_ReturnsNullForUnknownId()
|
||||
{
|
||||
// Act
|
||||
var result = await _store.FindAsync("nonexistent-provider", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SaveAsync_UpdatesExistingProvider()
|
||||
{
|
||||
// Arrange
|
||||
var original = new VexProvider(
|
||||
"ubuntu-csaf", "Ubuntu CSAF", VexProviderKind.Distro,
|
||||
[], VexProviderDiscovery.Empty, VexProviderTrust.Default, true);
|
||||
|
||||
var updated = new VexProvider(
|
||||
"ubuntu-csaf", "Canonical Ubuntu CSAF", VexProviderKind.Distro,
|
||||
[new Uri("https://ubuntu.com/security/")],
|
||||
VexProviderDiscovery.Empty, VexProviderTrust.Default, false);
|
||||
|
||||
// Act
|
||||
await _store.SaveAsync(original, CancellationToken.None);
|
||||
await _store.SaveAsync(updated, CancellationToken.None);
|
||||
var fetched = await _store.FindAsync("ubuntu-csaf", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.DisplayName.Should().Be("Canonical Ubuntu CSAF");
|
||||
fetched.Enabled.Should().BeFalse();
|
||||
fetched.BaseUris.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ListAsync_ReturnsAllProviders()
|
||||
{
|
||||
// Arrange
|
||||
var provider1 = new VexProvider(
|
||||
"aaa-provider", "AAA Provider", VexProviderKind.Vendor,
|
||||
[], VexProviderDiscovery.Empty, VexProviderTrust.Default, true);
|
||||
var provider2 = new VexProvider(
|
||||
"zzz-provider", "ZZZ Provider", VexProviderKind.Hub,
|
||||
[], VexProviderDiscovery.Empty, VexProviderTrust.Default, true);
|
||||
|
||||
await _store.SaveAsync(provider1, CancellationToken.None);
|
||||
await _store.SaveAsync(provider2, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var providers = await _store.ListAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
providers.Should().HaveCount(2);
|
||||
providers.Select(p => p.Id).Should().ContainInOrder("aaa-provider", "zzz-provider");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SaveAsync_PersistsTrustSettings()
|
||||
{
|
||||
// Arrange
|
||||
var cosign = new VexCosignTrust("https://accounts.google.com", "@redhat.com$");
|
||||
var vector = new TrustVector
|
||||
{
|
||||
Provenance = 0.9,
|
||||
Coverage = 0.8,
|
||||
Replayability = 0.7,
|
||||
};
|
||||
var weights = new TrustWeights { WP = 0.4, WC = 0.4, WR = 0.2 };
|
||||
var trust = new VexProviderTrust(0.9, cosign, ["ABCD1234", "EFGH5678"], vector, weights);
|
||||
var provider = new VexProvider(
|
||||
"trusted-provider", "Trusted Provider", VexProviderKind.Attestation,
|
||||
[], VexProviderDiscovery.Empty, trust, true);
|
||||
|
||||
// Act
|
||||
await _store.SaveAsync(provider, CancellationToken.None);
|
||||
var fetched = await _store.FindAsync("trusted-provider", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.Trust.Weight.Should().Be(0.9);
|
||||
fetched.Trust.Cosign.Should().NotBeNull();
|
||||
fetched.Trust.Cosign!.Issuer.Should().Be("https://accounts.google.com");
|
||||
fetched.Trust.Cosign.IdentityPattern.Should().Be("@redhat.com$");
|
||||
fetched.Trust.PgpFingerprints.Should().HaveCount(2);
|
||||
fetched.Trust.Vector.Should().Be(vector);
|
||||
fetched.Trust.Weights.Should().Be(weights);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.Persistence.Postgres;
|
||||
using StellaOps.Excititor.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Excititor.Persistence.Tests;
|
||||
|
||||
[Collection(ExcititorPostgresCollection.Name)]
|
||||
public sealed class PostgresVexTimelineEventStoreTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ExcititorPostgresFixture _fixture;
|
||||
private readonly PostgresVexTimelineEventStore _store;
|
||||
private readonly ExcititorDataSource _dataSource;
|
||||
private readonly string _tenantId = "tenant-" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
public PostgresVexTimelineEventStoreTests(ExcititorPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
var options = Options.Create(new PostgresOptions
|
||||
{
|
||||
ConnectionString = fixture.ConnectionString,
|
||||
SchemaName = fixture.SchemaName,
|
||||
AutoMigrate = false
|
||||
});
|
||||
|
||||
_dataSource = new ExcititorDataSource(options, NullLogger<ExcititorDataSource>.Instance);
|
||||
_store = new PostgresVexTimelineEventStore(_dataSource, NullLogger<PostgresVexTimelineEventStore>.Instance);
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.Fixture.RunMigrationsFromAssemblyAsync(
|
||||
typeof(ExcititorDataSource).Assembly,
|
||||
moduleName: "Excititor",
|
||||
resourcePrefix: "Migrations",
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InsertAndGetById_RoundTripsEvent()
|
||||
{
|
||||
// Arrange
|
||||
var evt = new TimelineEvent(
|
||||
eventId: "evt-" + Guid.NewGuid().ToString("N"),
|
||||
tenant: _tenantId,
|
||||
providerId: "redhat-csaf",
|
||||
streamId: "stream-1",
|
||||
eventType: "observation_created",
|
||||
traceId: "trace-123",
|
||||
justificationSummary: "Component not affected",
|
||||
createdAt: DateTimeOffset.UtcNow,
|
||||
evidenceHash: "sha256:abc123",
|
||||
payloadHash: "sha256:def456",
|
||||
attributes: ImmutableDictionary<string, string>.Empty.Add("cve", "CVE-2025-1234"));
|
||||
|
||||
// Act
|
||||
var id = await _store.InsertAsync(evt, CancellationToken.None);
|
||||
var fetched = await _store.GetByIdAsync(_tenantId, id, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched!.EventId.Should().Be(evt.EventId);
|
||||
fetched.ProviderId.Should().Be("redhat-csaf");
|
||||
fetched.EventType.Should().Be("observation_created");
|
||||
fetched.JustificationSummary.Should().Be("Component not affected");
|
||||
fetched.EvidenceHash.Should().Be("sha256:abc123");
|
||||
fetched.Attributes.Should().ContainKey("cve");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsNullForUnknownEvent()
|
||||
{
|
||||
// Act
|
||||
var result = await _store.GetByIdAsync(_tenantId, "nonexistent-event", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetRecentAsync_ReturnsEventsInDescendingOrder()
|
||||
{
|
||||
// Arrange
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var events = new[]
|
||||
{
|
||||
CreateEvent("evt-1", now.AddMinutes(-10)),
|
||||
CreateEvent("evt-2", now.AddMinutes(-5)),
|
||||
CreateEvent("evt-3", now)
|
||||
};
|
||||
|
||||
foreach (var evt in events)
|
||||
{
|
||||
await _store.InsertAsync(evt, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Act
|
||||
var recent = await _store.GetRecentAsync(_tenantId, limit: 10, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
recent.Should().HaveCount(3);
|
||||
recent[0].EventId.Should().Be("evt-3"); // Most recent first
|
||||
recent[1].EventId.Should().Be("evt-2");
|
||||
recent[2].EventId.Should().Be("evt-1");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FindByTraceIdAsync_ReturnsMatchingEvents()
|
||||
{
|
||||
// Arrange
|
||||
var traceId = "trace-" + Guid.NewGuid().ToString("N")[..8];
|
||||
var evt1 = CreateEvent("evt-a", DateTimeOffset.UtcNow, traceId: traceId);
|
||||
var evt2 = CreateEvent("evt-b", DateTimeOffset.UtcNow, traceId: traceId);
|
||||
var evt3 = CreateEvent("evt-c", DateTimeOffset.UtcNow, traceId: "other-trace");
|
||||
|
||||
await _store.InsertAsync(evt1, CancellationToken.None);
|
||||
await _store.InsertAsync(evt2, CancellationToken.None);
|
||||
await _store.InsertAsync(evt3, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var found = await _store.FindByTraceIdAsync(_tenantId, traceId, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
found.Should().HaveCount(2);
|
||||
found.Select(e => e.EventId).Should().Contain("evt-a", "evt-b");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CountAsync_ReturnsCorrectCount()
|
||||
{
|
||||
// Arrange
|
||||
await _store.InsertAsync(CreateEvent("evt-1", DateTimeOffset.UtcNow), CancellationToken.None);
|
||||
await _store.InsertAsync(CreateEvent("evt-2", DateTimeOffset.UtcNow), CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var count = await _store.CountAsync(_tenantId, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
count.Should().Be(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InsertManyAsync_InsertsMultipleEvents()
|
||||
{
|
||||
// Arrange
|
||||
var events = new[]
|
||||
{
|
||||
CreateEvent("batch-1", DateTimeOffset.UtcNow),
|
||||
CreateEvent("batch-2", DateTimeOffset.UtcNow),
|
||||
CreateEvent("batch-3", DateTimeOffset.UtcNow)
|
||||
};
|
||||
|
||||
// Act
|
||||
var inserted = await _store.InsertManyAsync(_tenantId, events, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
inserted.Should().Be(3);
|
||||
var count = await _store.CountAsync(_tenantId, CancellationToken.None);
|
||||
count.Should().Be(3);
|
||||
}
|
||||
|
||||
private TimelineEvent CreateEvent(string eventId, DateTimeOffset createdAt, string? traceId = null) =>
|
||||
new TimelineEvent(
|
||||
eventId: eventId,
|
||||
tenant: _tenantId,
|
||||
providerId: "test-provider",
|
||||
streamId: "stream-1",
|
||||
eventType: "test_event",
|
||||
traceId: traceId ?? "trace-default",
|
||||
justificationSummary: "Test event",
|
||||
createdAt: createdAt,
|
||||
evidenceHash: null,
|
||||
payloadHash: null,
|
||||
attributes: ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Excititor.Persistence.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
# Excititor Persistence Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0324-M | DONE | Revalidated 2026-01-07; maintainability audit for Excititor.Persistence.Tests. |
|
||||
| AUDIT-0324-T | DONE | Revalidated 2026-01-07; test coverage audit for Excititor.Persistence.Tests. |
|
||||
| AUDIT-0324-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| QA-DEVOPS-VERIFY-002-T | DONE | 2026-02-11: Added Rekor linkage behavioral tests (round-trip, pending ordering, missing-observation negative path) for `vex-rekor-linkage` run-001. |
|
||||
@@ -0,0 +1,320 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexQueryDeterminismTests.cs
|
||||
// Sprint: SPRINT_5100_0009_0003_excititor_tests
|
||||
// Task: EXCITITOR-5100-014
|
||||
// Description: Model S1 query determinism tests for Excititor VEX storage
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.Persistence.Postgres;
|
||||
using StellaOps.Excititor.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Query determinism tests for Excititor VEX storage operations.
|
||||
/// Implements Model S1 (Storage/Postgres) test requirements:
|
||||
/// - Explicit ORDER BY checks for all list queries
|
||||
/// - Same inputs → stable ordering
|
||||
/// - Repeated queries return consistent results
|
||||
/// </summary>
|
||||
[Collection(ExcititorPostgresCollection.Name)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Trait("Category", "QueryDeterminism")]
|
||||
public sealed class VexQueryDeterminismTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ExcititorPostgresFixture _fixture;
|
||||
private ExcititorDataSource _dataSource = null!;
|
||||
private PostgresAppendOnlyLinksetStore _linksetStore = null!;
|
||||
|
||||
public VexQueryDeterminismTests(ExcititorPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.Fixture.RunMigrationsFromAssemblyAsync(
|
||||
typeof(ExcititorDataSource).Assembly,
|
||||
moduleName: "Excititor",
|
||||
resourcePrefix: "Migrations",
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
// Fallback migration application if needed
|
||||
var resourceName = typeof(ExcititorDataSource).Assembly
|
||||
.GetManifestResourceNames()
|
||||
.FirstOrDefault(n => n.EndsWith("001_initial_schema.sql", StringComparison.OrdinalIgnoreCase));
|
||||
if (resourceName is not null)
|
||||
{
|
||||
await using var stream = typeof(ExcititorDataSource).Assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is not null)
|
||||
{
|
||||
using var reader = new StreamReader(stream);
|
||||
var sql = await reader.ReadToEndAsync();
|
||||
try { await _fixture.Fixture.ExecuteSqlAsync(sql); } catch { /* Ignore if already exists */ }
|
||||
}
|
||||
}
|
||||
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
var options = Options.Create(new PostgresOptions
|
||||
{
|
||||
ConnectionString = _fixture.ConnectionString,
|
||||
SchemaName = _fixture.SchemaName,
|
||||
AutoMigrate = false
|
||||
});
|
||||
|
||||
_dataSource = new ExcititorDataSource(options, NullLogger<ExcititorDataSource>.Instance);
|
||||
_linksetStore = new PostgresAppendOnlyLinksetStore(_dataSource, NullLogger<PostgresAppendOnlyLinksetStore>.Instance);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MutationLog_MultipleQueries_ReturnsDeterministicOrder()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = $"tenant-{Guid.NewGuid():N}"[..20];
|
||||
var vuln = $"CVE-2025-{Random.Shared.Next(10000, 99999)}";
|
||||
var product = $"pkg:npm/det-{Guid.NewGuid():N}@1.0.0";
|
||||
var scope = VexProductScope.Unknown(product);
|
||||
|
||||
var observations = Enumerable.Range(1, 5)
|
||||
.Select(i => new VexLinksetObservationRefModel($"obs-{i}", $"provider-{i}", i % 2 == 0 ? "affected" : "fixed", 0.5 + i * 0.1))
|
||||
.ToList();
|
||||
|
||||
AppendLinksetResult? lastResult = null;
|
||||
foreach (var obs in observations)
|
||||
{
|
||||
lastResult = await _linksetStore.AppendObservationAsync(tenant, vuln, product, obs, scope, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Act - Query mutation log multiple times
|
||||
var results1 = await _linksetStore.GetMutationLogAsync(tenant, lastResult!.Linkset.LinksetId, CancellationToken.None);
|
||||
var results2 = await _linksetStore.GetMutationLogAsync(tenant, lastResult.Linkset.LinksetId, CancellationToken.None);
|
||||
var results3 = await _linksetStore.GetMutationLogAsync(tenant, lastResult.Linkset.LinksetId, CancellationToken.None);
|
||||
|
||||
// Assert - All queries should return same sequence
|
||||
var seqs1 = results1.Select(m => m.SequenceNumber).ToList();
|
||||
var seqs2 = results2.Select(m => m.SequenceNumber).ToList();
|
||||
var seqs3 = results3.Select(m => m.SequenceNumber).ToList();
|
||||
|
||||
seqs1.Should().Equal(seqs2);
|
||||
seqs2.Should().Equal(seqs3);
|
||||
|
||||
// Verify ascending order
|
||||
seqs1.Should().BeInAscendingOrder();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindWithConflicts_MultipleQueries_ReturnsDeterministicOrder()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = $"tenant-{Guid.NewGuid():N}"[..20];
|
||||
|
||||
// Create multiple linksets with conflicts
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var vuln = $"CVE-2025-{10000 + i}";
|
||||
var product = $"pkg:npm/conflict-{i}-{Guid.NewGuid():N}@1.0.0";
|
||||
var disagreement = new VexObservationDisagreement($"provider-{i}", "not_affected", "component_not_present", 0.5 + i * 0.1);
|
||||
|
||||
await _linksetStore.AppendDisagreementAsync(tenant, vuln, product, disagreement, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Act - Query conflicts multiple times
|
||||
var results1 = await _linksetStore.FindWithConflictsAsync(tenant, limit: 10, CancellationToken.None);
|
||||
var results2 = await _linksetStore.FindWithConflictsAsync(tenant, limit: 10, CancellationToken.None);
|
||||
var results3 = await _linksetStore.FindWithConflictsAsync(tenant, limit: 10, CancellationToken.None);
|
||||
|
||||
// Assert - All queries should return same order
|
||||
var ids1 = results1.Select(ls => ls.LinksetId).ToList();
|
||||
var ids2 = results2.Select(ls => ls.LinksetId).ToList();
|
||||
var ids3 = results3.Select(ls => ls.LinksetId).ToList();
|
||||
|
||||
ids1.Should().Equal(ids2);
|
||||
ids2.Should().Equal(ids3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ObservationOrdering_MultipleProviders_MaintainsStableOrder()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = $"tenant-{Guid.NewGuid():N}"[..20];
|
||||
var vuln = $"CVE-2025-{Random.Shared.Next(10000, 99999)}";
|
||||
var product = $"pkg:maven/demo/obs-order-{Guid.NewGuid():N}@2.0.0";
|
||||
var scope = VexProductScope.Unknown(product);
|
||||
|
||||
// Add observations from different providers
|
||||
var observations = new[]
|
||||
{
|
||||
new VexLinksetObservationRefModel("obs-z", "provider-zebra", "affected", 0.7),
|
||||
new VexLinksetObservationRefModel("obs-a", "provider-alpha", "affected", 0.8),
|
||||
new VexLinksetObservationRefModel("obs-m", "provider-mike", "fixed", 0.9)
|
||||
};
|
||||
|
||||
await _linksetStore.AppendObservationsBatchAsync(tenant, vuln, product, observations, scope, CancellationToken.None);
|
||||
|
||||
// Act - Query the linkset multiple times
|
||||
var results = new List<AppendLinksetResult>();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
// Re-append to get current state (no changes expected)
|
||||
var result = await _linksetStore.AppendObservationsBatchAsync(tenant, vuln, product, observations, scope, CancellationToken.None);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
// Assert - Observation ordering should be consistent
|
||||
var firstOrder = results[0].Linkset.Observations
|
||||
.Select(o => $"{o.ProviderId}:{o.ObservationId}")
|
||||
.ToList();
|
||||
|
||||
results.Should().AllSatisfy(r =>
|
||||
{
|
||||
var order = r.Linkset.Observations
|
||||
.Select(o => $"{o.ProviderId}:{o.ObservationId}")
|
||||
.ToList();
|
||||
order.Should().Equal(firstOrder);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CountWithConflicts_MultipleQueries_ReturnsConsistentCount()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = $"tenant-{Guid.NewGuid():N}"[..20];
|
||||
|
||||
// Create a known number of linksets with conflicts
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var vuln = $"CVE-2025-{20000 + i}";
|
||||
var product = $"pkg:npm/count-{i}-{Guid.NewGuid():N}@1.0.0";
|
||||
var disagreement = new VexObservationDisagreement($"provider-c{i}", "not_affected", "component_not_present", 0.6);
|
||||
|
||||
await _linksetStore.AppendDisagreementAsync(tenant, vuln, product, disagreement, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Act - Query count multiple times
|
||||
var counts = new List<long>();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var count = await _linksetStore.CountWithConflictsAsync(tenant, CancellationToken.None);
|
||||
counts.Add(count);
|
||||
}
|
||||
|
||||
// Assert - All should return same count
|
||||
counts.Should().AllBeEquivalentTo(counts[0]);
|
||||
counts[0].Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentQueries_SameLinkset_AllReturnIdenticalResults()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = $"tenant-{Guid.NewGuid():N}"[..20];
|
||||
var vuln = $"CVE-2025-{Random.Shared.Next(10000, 99999)}";
|
||||
var product = $"pkg:npm/concurrent-{Guid.NewGuid():N}@1.0.0";
|
||||
var scope = VexProductScope.Unknown(product);
|
||||
var observation = new VexLinksetObservationRefModel("obs-c", "provider-c", "affected", 0.75);
|
||||
|
||||
var initial = await _linksetStore.AppendObservationAsync(tenant, vuln, product, observation, scope, CancellationToken.None);
|
||||
|
||||
// Act - 20 concurrent queries
|
||||
var tasks = Enumerable.Range(0, 20)
|
||||
.Select(_ => _linksetStore.AppendObservationAsync(tenant, vuln, product, observation, scope, CancellationToken.None).AsTask())
|
||||
.ToList();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert - All should return identical linkset
|
||||
var linksetIds = results.Select(r => r.Linkset.LinksetId).Distinct().ToList();
|
||||
linksetIds.Should().HaveCount(1);
|
||||
|
||||
results.Should().AllSatisfy(r =>
|
||||
{
|
||||
r.HadChanges.Should().BeFalse();
|
||||
r.Linkset.Observations.Should().HaveCount(1);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DifferentVulns_QueriedInParallel_EachReturnsCorrectResult()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = $"tenant-{Guid.NewGuid():N}"[..20];
|
||||
var vulns = Enumerable.Range(0, 10)
|
||||
.Select(i => $"CVE-2025-{30000 + i}")
|
||||
.ToList();
|
||||
|
||||
var products = vulns.Select((v, i) => $"pkg:npm/parallel-{i}-{Guid.NewGuid():N}@1.0.0").ToList();
|
||||
var scope = VexProductScope.Unknown("default");
|
||||
|
||||
// Create linksets for each
|
||||
var linksetIds = new List<string>();
|
||||
for (int i = 0; i < vulns.Count; i++)
|
||||
{
|
||||
var obs = new VexLinksetObservationRefModel($"obs-p{i}", $"provider-p{i}", "affected", 0.5 + i * 0.05);
|
||||
var result = await _linksetStore.AppendObservationAsync(tenant, vulns[i], products[i], obs, scope, CancellationToken.None);
|
||||
linksetIds.Add(result.Linkset.LinksetId);
|
||||
}
|
||||
|
||||
// Act - Query mutation logs in parallel
|
||||
var tasks = linksetIds.Select(id => _linksetStore.GetMutationLogAsync(tenant, id, CancellationToken.None).AsTask()).ToList();
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert - Each result should have correct linkset
|
||||
for (int i = 0; i < results.Length; i++)
|
||||
{
|
||||
results[i].Should().NotBeEmpty();
|
||||
results[i].All(m => m.SequenceNumber > 0).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyTenant_FindWithConflicts_ReturnsEmptyConsistently()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = $"empty-{Guid.NewGuid():N}"[..20];
|
||||
|
||||
// Act - Query empty tenant multiple times
|
||||
var results = new List<IReadOnlyList<VexLinkset>>();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var conflicts = await _linksetStore.FindWithConflictsAsync(tenant, limit: 10, CancellationToken.None);
|
||||
results.Add(conflicts);
|
||||
}
|
||||
|
||||
// Assert - All should return empty
|
||||
results.Should().AllSatisfy(r => r.Should().BeEmpty());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyTenant_CountWithConflicts_ReturnsZeroConsistently()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = $"empty-{Guid.NewGuid():N}"[..20];
|
||||
|
||||
// Act - Query empty tenant multiple times
|
||||
var counts = new List<long>();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var count = await _linksetStore.CountWithConflictsAsync(tenant, CancellationToken.None);
|
||||
counts.Add(count);
|
||||
}
|
||||
|
||||
// Assert - All should return zero
|
||||
counts.Should().AllBeEquivalentTo(0L);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexStatementIdempotencyTests.cs
|
||||
// Sprint: SPRINT_5100_0009_0003_excititor_tests
|
||||
// Task: EXCITITOR-5100-013
|
||||
// Description: Model S1 idempotency tests for Excititor VEX statement storage
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.Persistence.Postgres;
|
||||
using StellaOps.Excititor.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotency tests for Excititor VEX statement storage operations.
|
||||
/// Implements Model S1 (Storage/Postgres) test requirements:
|
||||
/// - Same VEX claim ID, same source snapshot → no duplicates
|
||||
/// - Append same observation twice → idempotent
|
||||
/// - Linkset updates are idempotent
|
||||
/// </summary>
|
||||
[Collection(ExcititorPostgresCollection.Name)]
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Trait("Category", "StorageIdempotency")]
|
||||
public sealed class VexStatementIdempotencyTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ExcititorPostgresFixture _fixture;
|
||||
private ExcititorDataSource _dataSource = null!;
|
||||
private PostgresAppendOnlyLinksetStore _linksetStore = null!;
|
||||
|
||||
public VexStatementIdempotencyTests(ExcititorPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.Fixture.RunMigrationsFromAssemblyAsync(
|
||||
typeof(ExcititorDataSource).Assembly,
|
||||
moduleName: "Excititor",
|
||||
resourcePrefix: "Migrations",
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
// Fallback migration application if needed
|
||||
var resourceName = typeof(ExcititorDataSource).Assembly
|
||||
.GetManifestResourceNames()
|
||||
.FirstOrDefault(n => n.EndsWith("001_initial_schema.sql", StringComparison.OrdinalIgnoreCase));
|
||||
if (resourceName is not null)
|
||||
{
|
||||
await using var stream = typeof(ExcititorDataSource).Assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is not null)
|
||||
{
|
||||
using var reader = new StreamReader(stream);
|
||||
var sql = await reader.ReadToEndAsync();
|
||||
try { await _fixture.Fixture.ExecuteSqlAsync(sql); } catch { /* Ignore if already exists */ }
|
||||
}
|
||||
}
|
||||
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
var options = Options.Create(new PostgresOptions
|
||||
{
|
||||
ConnectionString = _fixture.ConnectionString,
|
||||
SchemaName = _fixture.SchemaName,
|
||||
AutoMigrate = false
|
||||
});
|
||||
|
||||
_dataSource = new ExcititorDataSource(options, NullLogger<ExcititorDataSource>.Instance);
|
||||
_linksetStore = new PostgresAppendOnlyLinksetStore(_dataSource, NullLogger<PostgresAppendOnlyLinksetStore>.Instance);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendObservation_SameObservationTwice_Deduplicates()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = $"tenant-{Guid.NewGuid():N}"[..20];
|
||||
var vuln = $"CVE-2025-{Random.Shared.Next(10000, 99999)}";
|
||||
var product = $"pkg:npm/test-pkg-{Guid.NewGuid():N}@1.0.0";
|
||||
var scope = VexProductScope.Unknown(product);
|
||||
var observation = new VexLinksetObservationRefModel("obs-1", "provider-a", "not_affected", 0.9);
|
||||
|
||||
// Act - Append same observation twice
|
||||
var first = await _linksetStore.AppendObservationAsync(tenant, vuln, product, observation, scope, CancellationToken.None);
|
||||
var second = await _linksetStore.AppendObservationAsync(tenant, vuln, product, observation, scope, CancellationToken.None);
|
||||
|
||||
// Assert - Second append should not add duplicates
|
||||
first.WasCreated.Should().BeTrue();
|
||||
first.ObservationsAdded.Should().Be(1);
|
||||
|
||||
second.HadChanges.Should().BeFalse();
|
||||
second.Linkset.Observations.Should().HaveCount(1);
|
||||
second.SequenceNumber.Should().Be(first.SequenceNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendObservation_MultipleTimesWithSameData_IsIdempotent()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = $"tenant-{Guid.NewGuid():N}"[..20];
|
||||
var vuln = $"CVE-2025-{Random.Shared.Next(10000, 99999)}";
|
||||
var product = $"pkg:maven/demo/demo-{Guid.NewGuid():N}@1.0.0";
|
||||
var scope = VexProductScope.Unknown(product);
|
||||
var observation = new VexLinksetObservationRefModel("obs-idem", "provider-idem", "affected", 0.8);
|
||||
|
||||
// Act - Append 5 times
|
||||
var results = new List<AppendLinksetResult>();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var result = await _linksetStore.AppendObservationAsync(tenant, vuln, product, observation, scope, CancellationToken.None);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
// Assert - Only first should show creation, rest should be no-ops
|
||||
results[0].WasCreated.Should().BeTrue();
|
||||
for (int i = 1; i < results.Count; i++)
|
||||
{
|
||||
results[i].HadChanges.Should().BeFalse();
|
||||
}
|
||||
|
||||
// Final linkset should have exactly one observation
|
||||
var finalResult = results.Last();
|
||||
finalResult.Linkset.Observations.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendObservationsBatch_SameObservationsTwice_Deduplicates()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = $"tenant-{Guid.NewGuid():N}"[..20];
|
||||
var vuln = $"CVE-2025-{Random.Shared.Next(10000, 99999)}";
|
||||
var product = $"pkg:nuget/batch-pkg-{Guid.NewGuid():N}@1.0.0";
|
||||
var scope = VexProductScope.Unknown(product);
|
||||
var observations = new[]
|
||||
{
|
||||
new VexLinksetObservationRefModel("obs-b1", "provider-b", "affected", 0.7),
|
||||
new VexLinksetObservationRefModel("obs-b2", "provider-b", "fixed", 0.9)
|
||||
};
|
||||
|
||||
// Act - Append same batch twice
|
||||
var first = await _linksetStore.AppendObservationsBatchAsync(tenant, vuln, product, observations, scope, CancellationToken.None);
|
||||
var second = await _linksetStore.AppendObservationsBatchAsync(tenant, vuln, product, observations, scope, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
first.Linkset.Observations.Should().HaveCount(2);
|
||||
second.Linkset.Observations.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendDisagreement_SameDisagreementTwice_NoAdditionalConflict()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = $"tenant-{Guid.NewGuid():N}"[..20];
|
||||
var vuln = $"CVE-2025-{Random.Shared.Next(10000, 99999)}";
|
||||
var product = $"pkg:deb/debian/conflict-{Guid.NewGuid():N}@1.0.0";
|
||||
var disagreement = new VexObservationDisagreement("provider-c", "not_affected", "component_not_present", 0.6);
|
||||
|
||||
// Act - Append same disagreement twice
|
||||
var first = await _linksetStore.AppendDisagreementAsync(tenant, vuln, product, disagreement, CancellationToken.None);
|
||||
var second = await _linksetStore.AppendDisagreementAsync(tenant, vuln, product, disagreement, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
first.Linkset.HasConflicts.Should().BeTrue();
|
||||
second.Linkset.HasConflicts.Should().BeTrue();
|
||||
first.Linkset.LinksetId.Should().Be(second.Linkset.LinksetId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLinkset_SameParameters_ReturnsConsistentResult()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = $"tenant-{Guid.NewGuid():N}"[..20];
|
||||
var vuln = $"CVE-2025-{Random.Shared.Next(10000, 99999)}";
|
||||
var product = $"pkg:npm/consistent-{Guid.NewGuid():N}@1.0.0";
|
||||
var scope = VexProductScope.Unknown(product);
|
||||
var observation = new VexLinksetObservationRefModel("obs-cons", "provider-cons", "affected", 0.85);
|
||||
|
||||
await _linksetStore.AppendObservationAsync(tenant, vuln, product, observation, scope, CancellationToken.None);
|
||||
|
||||
// Act - Query multiple times
|
||||
var results = new List<AppendLinksetResult>();
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
// Append again to get the current state
|
||||
var result = await _linksetStore.AppendObservationAsync(tenant, vuln, product, observation, scope, CancellationToken.None);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
// Assert - All should return same linkset ID and observation count
|
||||
var linksetIds = results.Select(r => r.Linkset.LinksetId).Distinct().ToList();
|
||||
linksetIds.Should().HaveCount(1);
|
||||
|
||||
results.Should().AllSatisfy(r =>
|
||||
{
|
||||
r.Linkset.Observations.Should().HaveCount(1);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MutationLog_MultipleAppends_MaintainsAscendingOrder()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = $"tenant-{Guid.NewGuid():N}"[..20];
|
||||
var vuln = $"CVE-2025-{Random.Shared.Next(10000, 99999)}";
|
||||
var product = $"pkg:npm/ordered-{Guid.NewGuid():N}@1.0.0";
|
||||
var scope = VexProductScope.Unknown(product);
|
||||
|
||||
var observations = Enumerable.Range(1, 5)
|
||||
.Select(i => new VexLinksetObservationRefModel($"obs-{i}", "provider-ord", i % 2 == 0 ? "affected" : "fixed", 0.5 + i * 0.1))
|
||||
.ToList();
|
||||
|
||||
AppendLinksetResult? lastResult = null;
|
||||
foreach (var obs in observations)
|
||||
{
|
||||
lastResult = await _linksetStore.AppendObservationAsync(tenant, vuln, product, obs, scope, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Act
|
||||
var mutations = await _linksetStore.GetMutationLogAsync(tenant, lastResult!.Linkset.LinksetId, CancellationToken.None);
|
||||
|
||||
// Assert - Sequence numbers should be ascending
|
||||
mutations.Select(m => m.SequenceNumber).Should().BeInAscendingOrder();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DifferentTenants_SameLinksetParams_AreSeparate()
|
||||
{
|
||||
// Arrange
|
||||
var vuln = $"CVE-2025-{Random.Shared.Next(10000, 99999)}";
|
||||
var product = $"pkg:npm/tenant-iso-{Guid.NewGuid():N}@1.0.0";
|
||||
var scope = VexProductScope.Unknown(product);
|
||||
var observation = new VexLinksetObservationRefModel("obs-t", "provider-t", "affected", 0.7);
|
||||
|
||||
var tenant1 = $"tenant1-{Guid.NewGuid():N}"[..20];
|
||||
var tenant2 = $"tenant2-{Guid.NewGuid():N}"[..20];
|
||||
|
||||
// Act
|
||||
var result1 = await _linksetStore.AppendObservationAsync(tenant1, vuln, product, observation, scope, CancellationToken.None);
|
||||
var result2 = await _linksetStore.AppendObservationAsync(tenant2, vuln, product, observation, scope, CancellationToken.None);
|
||||
|
||||
// Assert - Different linksets
|
||||
result1.Linkset.LinksetId.Should().NotBe(result2.Linkset.LinksetId);
|
||||
result1.WasCreated.Should().BeTrue();
|
||||
result2.WasCreated.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user