up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core.Evidence;
|
||||
using StellaOps.Excititor.Storage.Postgres;
|
||||
using StellaOps.Excititor.Storage.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Postgres.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 Task InitializeAsync()
|
||||
{
|
||||
await _fixture.Fixture.RunMigrationsFromAssemblyAsync(
|
||||
typeof(ExcititorDataSource).Assembly,
|
||||
moduleName: "Excititor",
|
||||
resourcePrefix: "Migrations",
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindByIdAsync_ReturnsNullForUnknownId()
|
||||
{
|
||||
// Act
|
||||
var result = await _store.FindByIdAsync(_tenantId, "nonexistent", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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,226 @@
|
||||
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.Storage.Postgres;
|
||||
using StellaOps.Excititor.Storage.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Postgres.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 Task InitializeAsync()
|
||||
{
|
||||
await _fixture.Fixture.RunMigrationsFromAssemblyAsync(
|
||||
typeof(ExcititorDataSource).Assembly,
|
||||
moduleName: "Excititor",
|
||||
resourcePrefix: "Migrations",
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsNullForUnknownId()
|
||||
{
|
||||
// Act
|
||||
var result = await _store.GetByIdAsync(_tenantId, "nonexistent", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
private VexObservation CreateObservation(string observationId, string providerId, string vulnId, string productKey)
|
||||
{
|
||||
var now = 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,156 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Storage.Postgres;
|
||||
using StellaOps.Excititor.Storage.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Postgres.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 Task InitializeAsync()
|
||||
{
|
||||
await _fixture.Fixture.RunMigrationsFromAssemblyAsync(
|
||||
typeof(ExcititorDataSource).Assembly,
|
||||
moduleName: "Excititor",
|
||||
resourcePrefix: "Migrations",
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindAsync_ReturnsNullForUnknownId()
|
||||
{
|
||||
// Act
|
||||
var result = await _store.FindAsync("nonexistent-provider", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_PersistsTrustSettings()
|
||||
{
|
||||
// Arrange
|
||||
var cosign = new VexCosignTrust("https://accounts.google.com", "@redhat.com$");
|
||||
var trust = new VexProviderTrust(0.9, cosign, ["ABCD1234", "EFGH5678"]);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.Storage.Postgres;
|
||||
using StellaOps.Excititor.Storage.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Postgres.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 Task InitializeAsync()
|
||||
{
|
||||
await _fixture.Fixture.RunMigrationsFromAssemblyAsync(
|
||||
typeof(ExcititorDataSource).Assembly,
|
||||
moduleName: "Excititor",
|
||||
resourcePrefix: "Migrations",
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsNullForUnknownEvent()
|
||||
{
|
||||
// Act
|
||||
var result = await _store.GetByIdAsync(_tenantId, "nonexistent-event", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
Reference in New Issue
Block a user