up
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using StellaOps.Unknowns.Core.Models;
|
||||
using StellaOps.Unknowns.Storage.Postgres.Repositories;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Unknowns.Storage.Postgres.Tests;
|
||||
|
||||
public sealed class PostgresUnknownRepositoryTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:16")
|
||||
.Build();
|
||||
|
||||
private NpgsqlDataSource _dataSource = null!;
|
||||
private PostgresUnknownRepository _repository = null!;
|
||||
private const string TestTenantId = "test-tenant";
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _postgres.StartAsync();
|
||||
|
||||
var connectionString = _postgres.GetConnectionString();
|
||||
_dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
|
||||
// Run schema migrations
|
||||
await RunMigrationsAsync();
|
||||
|
||||
_repository = new PostgresUnknownRepository(
|
||||
_dataSource,
|
||||
NullLogger<PostgresUnknownRepository>.Instance);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
await _postgres.DisposeAsync();
|
||||
}
|
||||
|
||||
private async Task RunMigrationsAsync()
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync();
|
||||
|
||||
// Create schema and types
|
||||
const string schema = """
|
||||
CREATE SCHEMA IF NOT EXISTS unknowns;
|
||||
CREATE SCHEMA IF NOT EXISTS unknowns_app;
|
||||
|
||||
CREATE OR REPLACE FUNCTION unknowns_app.require_current_tenant()
|
||||
RETURNS TEXT
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant TEXT;
|
||||
BEGIN
|
||||
v_tenant := current_setting('app.tenant_id', true);
|
||||
IF v_tenant IS NULL OR v_tenant = '' THEN
|
||||
RAISE EXCEPTION 'app.tenant_id session variable not set';
|
||||
END IF;
|
||||
RETURN v_tenant;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.subject_type AS ENUM (
|
||||
'package', 'ecosystem', 'version', 'sbom_edge', 'file', 'runtime'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.unknown_kind AS ENUM (
|
||||
'missing_sbom', 'ambiguous_package', 'missing_feed', 'unresolved_edge',
|
||||
'no_version_info', 'unknown_ecosystem', 'partial_match',
|
||||
'version_range_unbounded', 'unsupported_format', 'transitive_gap'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.unknown_severity AS ENUM (
|
||||
'critical', 'high', 'medium', 'low', 'info'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE unknowns.resolution_type AS ENUM (
|
||||
'feed_updated', 'sbom_provided', 'manual_mapping',
|
||||
'superseded', 'false_positive', 'wont_fix'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS unknowns.unknown (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
subject_hash CHAR(64) NOT NULL,
|
||||
subject_type unknowns.subject_type NOT NULL,
|
||||
subject_ref TEXT NOT NULL,
|
||||
kind unknowns.unknown_kind NOT NULL,
|
||||
severity unknowns.unknown_severity,
|
||||
context JSONB NOT NULL DEFAULT '{}',
|
||||
source_scan_id UUID,
|
||||
source_graph_id UUID,
|
||||
source_sbom_digest TEXT,
|
||||
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
valid_to TIMESTAMPTZ,
|
||||
sys_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
sys_to TIMESTAMPTZ,
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolution_type unknowns.resolution_type,
|
||||
resolution_ref TEXT,
|
||||
resolution_notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by TEXT NOT NULL DEFAULT 'system',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_unknown_one_open_per_subject
|
||||
ON unknowns.unknown (tenant_id, subject_hash, kind)
|
||||
WHERE valid_to IS NULL AND sys_to IS NULL;
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(schema, connection);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ShouldCreateUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var subjectRef = "pkg:npm/lodash@4.17.21";
|
||||
var kind = UnknownKind.MissingFeed;
|
||||
var severity = UnknownSeverity.Medium;
|
||||
|
||||
// Act
|
||||
var result = await _repository.CreateAsync(
|
||||
TestTenantId,
|
||||
UnknownSubjectType.Package,
|
||||
subjectRef,
|
||||
kind,
|
||||
severity,
|
||||
"""{"ecosystem": "npm"}""",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"test-user",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.TenantId.Should().Be(TestTenantId);
|
||||
result.SubjectRef.Should().Be(subjectRef);
|
||||
result.Kind.Should().Be(kind);
|
||||
result.Severity.Should().Be(severity);
|
||||
result.IsOpen.Should().BeTrue();
|
||||
result.IsResolved.Should().BeFalse();
|
||||
result.IsCurrent.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ShouldReturnUnknown_WhenExists()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _repository.CreateAsync(
|
||||
TestTenantId,
|
||||
UnknownSubjectType.Package,
|
||||
"pkg:npm/axios@0.21.0",
|
||||
UnknownKind.AmbiguousPackage,
|
||||
UnknownSeverity.Low,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"test-user",
|
||||
CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var result = await _repository.GetByIdAsync(TestTenantId, created.Id, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(created.Id);
|
||||
result.SubjectRef.Should().Be("pkg:npm/axios@0.21.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ShouldReturnNull_WhenNotExists()
|
||||
{
|
||||
// Act
|
||||
var result = await _repository.GetByIdAsync(TestTenantId, Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOpenUnknownsAsync_ShouldReturnOnlyOpenUnknowns()
|
||||
{
|
||||
// Arrange
|
||||
await _repository.CreateAsync(
|
||||
TestTenantId,
|
||||
UnknownSubjectType.Package,
|
||||
"pkg:npm/open1@1.0.0",
|
||||
UnknownKind.MissingFeed,
|
||||
UnknownSeverity.High,
|
||||
null, null, null, null, "test-user", CancellationToken.None);
|
||||
|
||||
var toResolve = await _repository.CreateAsync(
|
||||
TestTenantId,
|
||||
UnknownSubjectType.Package,
|
||||
"pkg:npm/resolved@1.0.0",
|
||||
UnknownKind.MissingFeed,
|
||||
UnknownSeverity.Medium,
|
||||
null, null, null, null, "test-user", CancellationToken.None);
|
||||
|
||||
await _repository.ResolveAsync(
|
||||
TestTenantId,
|
||||
toResolve.Id,
|
||||
ResolutionType.FeedUpdated,
|
||||
null,
|
||||
"Feed now covers this package",
|
||||
"test-user",
|
||||
CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var results = await _repository.GetOpenUnknownsAsync(TestTenantId, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
results.Should().HaveCount(1);
|
||||
results[0].SubjectRef.Should().Be("pkg:npm/open1@1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ShouldMarkAsResolved()
|
||||
{
|
||||
// Arrange
|
||||
var created = await _repository.CreateAsync(
|
||||
TestTenantId,
|
||||
UnknownSubjectType.Package,
|
||||
"pkg:npm/resolvable@2.0.0",
|
||||
UnknownKind.MissingSbom,
|
||||
UnknownSeverity.High,
|
||||
null, null, null, null, "test-user", CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var resolved = await _repository.ResolveAsync(
|
||||
TestTenantId,
|
||||
created.Id,
|
||||
ResolutionType.SbomProvided,
|
||||
"sbom://digest/abc123",
|
||||
"SBOM uploaded by vendor",
|
||||
"test-user",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
resolved.IsResolved.Should().BeTrue();
|
||||
resolved.ResolutionType.Should().Be(ResolutionType.SbomProvided);
|
||||
resolved.ResolutionRef.Should().Be("sbom://digest/abc123");
|
||||
resolved.ValidTo.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CountByKindAsync_ShouldReturnCorrectCounts()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = "count-test-tenant";
|
||||
|
||||
await _repository.CreateAsync(tenant, UnknownSubjectType.Package,
|
||||
"pkg:1", UnknownKind.MissingFeed, null, null, null, null, null, "user", CancellationToken.None);
|
||||
await _repository.CreateAsync(tenant, UnknownSubjectType.Package,
|
||||
"pkg:2", UnknownKind.MissingFeed, null, null, null, null, null, "user", CancellationToken.None);
|
||||
await _repository.CreateAsync(tenant, UnknownSubjectType.Package,
|
||||
"pkg:3", UnknownKind.AmbiguousPackage, null, null, null, null, null, "user", CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var counts = await _repository.CountByKindAsync(tenant, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
counts.Should().ContainKey(UnknownKind.MissingFeed);
|
||||
counts[UnknownKind.MissingFeed].Should().Be(2);
|
||||
counts.Should().ContainKey(UnknownKind.AmbiguousPackage);
|
||||
counts[UnknownKind.AmbiguousPackage].Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsOfAsync_ShouldReturnHistoricalState()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = "temporal-test-tenant";
|
||||
var beforeCreate = DateTimeOffset.UtcNow.AddSeconds(-1);
|
||||
|
||||
var created = await _repository.CreateAsync(tenant, UnknownSubjectType.Package,
|
||||
"pkg:temporal@1.0.0", UnknownKind.NoVersionInfo, null, null, null, null, null, "user", CancellationToken.None);
|
||||
|
||||
var afterCreate = DateTimeOffset.UtcNow.AddSeconds(1);
|
||||
|
||||
// Act
|
||||
var beforeResults = await _repository.AsOfAsync(tenant, beforeCreate, cancellationToken: CancellationToken.None);
|
||||
var afterResults = await _repository.AsOfAsync(tenant, afterCreate, cancellationToken: CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
beforeResults.Should().BeEmpty();
|
||||
afterResults.Should().HaveCount(1);
|
||||
afterResults[0].Id.Should().Be(created.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SupersedeAsync_ShouldSetSysTo()
|
||||
{
|
||||
// Arrange
|
||||
var tenant = "supersede-test-tenant";
|
||||
var created = await _repository.CreateAsync(tenant, UnknownSubjectType.Package,
|
||||
"pkg:supersede@1.0.0", UnknownKind.PartialMatch, null, null, null, null, null, "user", CancellationToken.None);
|
||||
|
||||
// Act
|
||||
await _repository.SupersedeAsync(tenant, created.Id, "new-unknown-id", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var result = await _repository.GetByIdAsync(tenant, created.Id, CancellationToken.None);
|
||||
// After supersede, sys_to is set, so GetById (which filters sys_to IS NULL) returns null
|
||||
result.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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.Unknowns.Storage.Postgres.Tests</RootNamespace>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.4.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Unknowns.Storage.Postgres\StellaOps.Unknowns.Storage.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user