consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

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

View File

@@ -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();
}
}

View File

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

View File

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

View File

@@ -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"));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
}
}