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,25 @@
# IssuerDirectory Persistence Tests Agent Charter
## Mission
- Validate IssuerDirectory persistence mappings and PostgreSQL behavior.
## Responsibilities
- Keep integration tests deterministic with isolated data.
- Ensure database-dependent tests are clearly categorized and gated.
## Required Reading
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/issuer-directory/architecture.md
- docs/modules/platform/architecture-overview.md
## Working Directory & Scope
- Primary: src/IssuerDirectory/__Tests/StellaOps.IssuerDirectory.Persistence.Tests
- Allowed shared projects: src/IssuerDirectory/__Libraries/StellaOps.IssuerDirectory.Persistence
## Testing Expectations
- Add explicit skips or gating when PostgreSQL/Docker is unavailable.
- Prefer fixed timestamps or tolerances where time is asserted.
## Working Agreement
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
- Keep outputs deterministic and avoid non-ASCII logs.

View File

@@ -0,0 +1,47 @@
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public sealed partial class IssuerAuditSinkTests
{
private async Task<string> SeedIssuerAsync()
{
var issuerId = Guid.NewGuid().ToString();
var now = DateTimeOffset.UtcNow;
var issuer = new IssuerRecord
{
Id = issuerId,
TenantId = _tenantId,
Slug = $"test-issuer-{Guid.NewGuid():N}",
DisplayName = "Test Issuer",
Description = "Test issuer for audit tests",
Contact = new IssuerContact(null, null, null, null),
Metadata = new IssuerMetadata(null, null, null, null, [], new Dictionary<string, string>()),
Endpoints = [],
Tags = [],
IsSystemSeed = false,
CreatedAtUtc = now,
CreatedBy = "test@test.com",
UpdatedAtUtc = now,
UpdatedBy = "test@test.com"
};
await _issuerRepository.UpsertAsync(issuer, CancellationToken.None);
return issuerId;
}
private IssuerAuditEntry CreateAuditEntry(
string action,
string? reason,
IReadOnlyDictionary<string, string>? metadata = null,
DateTimeOffset? timestamp = null)
{
return new IssuerAuditEntry(
_tenantId,
_issuerId,
action,
timestamp ?? DateTimeOffset.UtcNow,
"test@test.com",
reason,
metadata);
}
}

View File

@@ -0,0 +1,43 @@
using FluentAssertions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public sealed partial class IssuerAuditSinkTests
{
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Write_PersistsMetadataAsync()
{
var metadata = new Dictionary<string, string>
{
["oldSlug"] = "old-issuer",
["newSlug"] = "new-issuer"
};
var entry = CreateAuditEntry("issuer.slug.changed", "Slug updated", metadata);
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.Details.Should().ContainKey("oldSlug");
persisted.Details["oldSlug"].Should().Be("old-issuer");
persisted.Details.Should().ContainKey("newSlug");
persisted.Details["newSlug"].Should().Be("new-issuer");
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Write_PersistsEmptyMetadataAsync()
{
var entry = CreateAuditEntry("issuer.deleted", "Issuer removed");
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.Details.Should().NotBeNull();
persisted.Details.Should().BeEmpty();
}
}

View File

@@ -0,0 +1,65 @@
using Npgsql;
using System.Text.Json;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public sealed partial class IssuerAuditSinkTests
{
private async Task<AuditEntryDto?> ReadAuditEntryAsync(string tenantId, string issuerId)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", CancellationToken.None);
const string sql = """
SELECT actor, action, reason, details, occurred_at
FROM issuer.audit
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
ORDER BY occurred_at DESC
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("tenantId", Guid.Parse(tenantId));
command.Parameters.AddWithValue("issuerId", Guid.Parse(issuerId));
await using var reader = await command.ExecuteReaderAsync();
if (!await reader.ReadAsync())
{
return null;
}
var detailsJson = reader.GetString(3);
var details = JsonSerializer.Deserialize<Dictionary<string, string>>(detailsJson) ?? [];
return new AuditEntryDto(
reader.GetString(0),
reader.GetString(1),
reader.IsDBNull(2) ? null : reader.GetString(2),
details,
reader.GetDateTime(4));
}
private async Task<int> CountAuditEntriesAsync(string tenantId, string issuerId)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", CancellationToken.None);
const string sql = """
SELECT COUNT(*)
FROM issuer.audit
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
""";
await using var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("tenantId", Guid.Parse(tenantId));
command.Parameters.AddWithValue("issuerId", Guid.Parse(issuerId));
var result = await command.ExecuteScalarAsync();
return Convert.ToInt32(result);
}
private sealed record AuditEntryDto(
string Actor,
string Action,
string? Reason,
Dictionary<string, string> Details,
DateTime OccurredAt);
}

View File

@@ -0,0 +1,51 @@
using FluentAssertions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public sealed partial class IssuerAuditSinkTests
{
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Write_PersistsNullReasonAsync()
{
var entry = CreateAuditEntry("issuer.updated", null);
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.Reason.Should().BeNull();
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Write_PersistsTimestampCorrectlyAsync()
{
var now = DateTimeOffset.UtcNow;
var entry = CreateAuditEntry("issuer.key.added", "Key added", timestamp: now);
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.OccurredAt.Should().BeCloseTo(now.UtcDateTime, TimeSpan.FromSeconds(1));
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Write_PersistsMultipleEntriesAsync()
{
var entry1 = CreateAuditEntry("issuer.created", "Created");
var entry2 = CreateAuditEntry("issuer.updated", "Updated");
var entry3 = CreateAuditEntry("issuer.key.added", "Key added");
await _auditSink.WriteAsync(entry1, CancellationToken.None);
await _auditSink.WriteAsync(entry2, CancellationToken.None);
await _auditSink.WriteAsync(entry3, CancellationToken.None);
var count = await CountAuditEntriesAsync(_tenantId, _issuerId);
count.Should().Be(3);
}
}

View File

@@ -0,0 +1,44 @@
using FluentAssertions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public sealed partial class IssuerAuditSinkTests
{
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Write_PersistsAuditEntryAsync()
{
var entry = CreateAuditEntry("issuer.created", "Issuer was created");
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.Action.Should().Be("issuer.created");
persisted.Reason.Should().Be("Issuer was created");
persisted.Actor.Should().Be("test@test.com");
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Write_PersistsActorCorrectlyAsync()
{
var entry = new IssuerAuditEntry(
_tenantId,
_issuerId,
"issuer.trust.changed",
DateTimeOffset.UtcNow,
"admin@company.com",
"Trust level modified",
null);
await _auditSink.WriteAsync(entry, CancellationToken.None);
var persisted = await ReadAuditEntryAsync(entry.TenantId, entry.IssuerId);
persisted.Should().NotBeNull();
persisted!.Actor.Should().Be("admin@company.com");
}
}

View File

@@ -0,0 +1,41 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.IssuerDirectory.Persistence.Postgres;
using StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
using Xunit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
[Collection(IssuerDirectoryPostgresCollection.Name)]
public sealed partial class IssuerAuditSinkTests : IAsyncLifetime
{
private readonly IssuerDirectoryPostgresFixture _fixture;
private readonly PostgresIssuerRepository _issuerRepository;
private readonly PostgresIssuerAuditSink _auditSink;
private readonly IssuerDirectoryDataSource _dataSource;
private readonly string _tenantId = Guid.NewGuid().ToString();
private string _issuerId = null!;
public IssuerAuditSinkTests(IssuerDirectoryPostgresFixture fixture)
{
_fixture = fixture;
var options = new PostgresOptions
{
ConnectionString = fixture.ConnectionString,
SchemaName = fixture.SchemaName
};
_dataSource = new IssuerDirectoryDataSource(Options.Create(options), NullLogger<IssuerDirectoryDataSource>.Instance);
_issuerRepository = new PostgresIssuerRepository(_dataSource, NullLogger<PostgresIssuerRepository>.Instance);
_auditSink = new PostgresIssuerAuditSink(_dataSource, NullLogger<PostgresIssuerAuditSink>.Instance);
}
public async ValueTask InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
_issuerId = await SeedIssuerAsync();
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

View File

@@ -0,0 +1,51 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Persistence.Extensions;
using StellaOps.IssuerDirectory.Persistence.Postgres;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public class IssuerDirectoryPersistenceExtensionsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddIssuerDirectoryPersistence_RegistersOptionsViaIOptions()
{
var services = new ServiceCollection();
services.AddIssuerDirectoryPersistence(options =>
{
options.ConnectionString = "Host=localhost;Database=issuer;Username=postgres;Password=postgres";
options.SchemaName = "custom_schema";
});
var provider = services.BuildServiceProvider();
var opts = provider.GetRequiredService<IOptions<PostgresOptions>>().Value;
opts.Should().NotBeNull();
opts.ConnectionString.Should().Be("Host=localhost;Database=issuer;Username=postgres;Password=postgres");
opts.SchemaName.Should().Be("custom_schema");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void AddIssuerDirectoryPersistence_RegistersRepositories()
{
var services = new ServiceCollection();
services.AddIssuerDirectoryPersistence(opts =>
{
opts.ConnectionString = "Host=localhost;Database=issuer;Username=postgres;Password=postgres";
opts.SchemaName = "issuer";
});
services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IssuerDirectoryDataSource));
services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IIssuerRepository));
services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IIssuerKeyRepository));
services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IIssuerTrustRepository));
services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IIssuerAuditSink));
}
}

View File

@@ -0,0 +1,9 @@
using Xunit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
[CollectionDefinition(Name)]
public sealed class IssuerDirectoryPostgresCollection : ICollectionFixture<IssuerDirectoryPostgresFixture>
{
public const string Name = "IssuerDirectoryPostgresCollection";
}

View File

@@ -0,0 +1,14 @@
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Testing;
using StellaOps.IssuerDirectory.Persistence.Postgres;
using System.Reflection;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public sealed class IssuerDirectoryPostgresFixture : PostgresIntegrationFixture
{
protected override Assembly? GetMigrationAssembly() => typeof(IssuerDirectoryDataSource).Assembly;
protected override string GetModuleName() => "issuer";
protected override string? GetResourcePrefix() => null;
protected override ILogger Logger => Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
}

View File

@@ -0,0 +1,116 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Persistence.Postgres;
using StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public class IssuerKeyRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixture>
{
private readonly IssuerDirectoryPostgresFixture _fixture;
public IssuerKeyRepositoryTests(IssuerDirectoryPostgresFixture fixture)
{
_fixture = fixture;
}
private PostgresIssuerRepository CreateIssuerRepo() =>
new(new IssuerDirectoryDataSource(Options.Create(_fixture.Fixture.CreateOptions()), NullLogger<IssuerDirectoryDataSource>.Instance),
NullLogger<PostgresIssuerRepository>.Instance);
private PostgresIssuerKeyRepository CreateKeyRepo() =>
new(new IssuerDirectoryDataSource(Options.Create(_fixture.Fixture.CreateOptions()), NullLogger<IssuerDirectoryDataSource>.Instance),
NullLogger<PostgresIssuerKeyRepository>.Instance);
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task AddKey_And_List_WorksAsync()
{
var tenant = Guid.NewGuid().ToString();
var issuerId = Guid.NewGuid().ToString();
var issuerRepo = CreateIssuerRepo();
var keyRepo = CreateKeyRepo();
var timestamp = DateTimeOffset.UtcNow;
var issuer = IssuerRecord.Create(
id: issuerId,
tenantId: tenant,
displayName: "Vendor X",
slug: "vendor-x",
description: null,
contact: new IssuerContact(null, null, null, null),
metadata: new IssuerMetadata(null, null, null, null, null, null),
endpoints: null,
tags: null,
timestampUtc: timestamp,
actor: "test",
isSystemSeed: false);
await issuerRepo.UpsertAsync(issuer, CancellationToken.None);
var key = IssuerKeyRecord.Create(
id: Guid.NewGuid().ToString(),
issuerId: issuerId,
tenantId: tenant,
type: IssuerKeyType.Ed25519PublicKey,
material: new IssuerKeyMaterial("pem", "pubkey"),
fingerprint: "fp-1",
createdAtUtc: DateTimeOffset.UtcNow,
createdBy: "test",
expiresAtUtc: null,
replacesKeyId: null);
await keyRepo.UpsertAsync(key, CancellationToken.None);
var keys = await keyRepo.ListAsync(tenant, issuerId, CancellationToken.None);
keys.Should().ContainSingle(k => k.IssuerId == issuerId);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task GetByFingerprint_ReturnsKeyAsync()
{
var tenant = Guid.NewGuid().ToString();
var issuerId = Guid.NewGuid().ToString();
var issuerRepo = CreateIssuerRepo();
var keyRepo = CreateKeyRepo();
var issuer = IssuerRecord.Create(
id: issuerId,
tenantId: tenant,
displayName: "Vendor Y",
slug: "vendor-y",
description: null,
contact: new IssuerContact(null, null, null, null),
metadata: new IssuerMetadata(null, null, null, null, null, null),
endpoints: null,
tags: null,
timestampUtc: DateTimeOffset.Parse("2026-01-02T01:00:00Z"),
actor: "test",
isSystemSeed: false);
await issuerRepo.UpsertAsync(issuer, CancellationToken.None);
var key = IssuerKeyRecord.Create(
id: Guid.NewGuid().ToString(),
issuerId: issuerId,
tenantId: tenant,
type: IssuerKeyType.Ed25519PublicKey,
material: new IssuerKeyMaterial("pem", "pubkey-2"),
fingerprint: "fp-lookup",
createdAtUtc: DateTimeOffset.Parse("2026-01-02T01:05:00Z"),
createdBy: "test",
expiresAtUtc: null,
replacesKeyId: null);
await keyRepo.UpsertAsync(key, CancellationToken.None);
var fetched = await keyRepo.GetByFingerprintAsync(tenant, issuerId, "fp-lookup", CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Fingerprint.Should().Be("fp-lookup");
fetched.Type.Should().Be(IssuerKeyType.Ed25519PublicKey);
}
}

View File

@@ -0,0 +1,102 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Persistence.Postgres;
using StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public class IssuerRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixture>
{
private readonly IssuerDirectoryPostgresFixture _fixture;
public IssuerRepositoryTests(IssuerDirectoryPostgresFixture fixture)
{
_fixture = fixture;
}
private PostgresIssuerRepository CreateRepository()
{
var dataSource = new IssuerDirectoryDataSource(
Options.Create(_fixture.Fixture.CreateOptions()),
NullLogger<IssuerDirectoryDataSource>.Instance);
return new PostgresIssuerRepository(dataSource, NullLogger<PostgresIssuerRepository>.Instance);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task UpsertAndGet_Works_For_TenantAsync()
{
var repo = CreateRepository();
var tenant = Guid.NewGuid().ToString();
var issuerId = Guid.NewGuid().ToString();
var timestamp = DateTimeOffset.UtcNow;
var record = IssuerRecord.Create(
id: issuerId,
tenantId: tenant,
displayName: "Acme Corp",
slug: "acme",
description: "Test issuer",
contact: new IssuerContact("security@acme.test", null, null, null),
metadata: new IssuerMetadata(null, null, null, null, null, null),
endpoints: new[] { new IssuerEndpoint("csaf", new Uri("https://acme.test/csaf"), null, false) },
tags: new[] { "vendor", "csaf" },
timestampUtc: timestamp,
actor: "test",
isSystemSeed: false);
await repo.UpsertAsync(record, CancellationToken.None);
var fetched = await repo.GetAsync(tenant, issuerId, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Slug.Should().Be("acme");
fetched.DisplayName.Should().Be("Acme Corp");
fetched.Endpoints.Should().HaveCount(1);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task UpsertAndGet_PersistsMetadataAndTagsAsync()
{
var repo = CreateRepository();
var tenant = Guid.NewGuid().ToString();
var issuerId = Guid.NewGuid().ToString();
var timestamp = DateTimeOffset.Parse("2026-01-02T00:00:00Z");
var record = IssuerRecord.Create(
id: issuerId,
tenantId: tenant,
displayName: "Contoso",
slug: "contoso",
description: "Metadata check",
contact: new IssuerContact("security@contoso.test", "555-0100", new Uri("https://contoso.test"), "UTC"),
metadata: new IssuerMetadata(
cveOrgId: "contoso-org",
csafPublisherId: "contoso-publisher",
securityAdvisoriesUrl: new Uri("https://contoso.test/advisories"),
catalogUrl: null,
supportedLanguages: new[] { "en", "fr" },
attributes: new Dictionary<string, string> { ["tier"] = "gold" }),
endpoints: new[]
{
new IssuerEndpoint("vex", new Uri("https://contoso.test/vex"), "csaf", true)
},
tags: new[] { "vendor", "priority" },
timestampUtc: timestamp,
actor: "test",
isSystemSeed: false);
await repo.UpsertAsync(record, CancellationToken.None);
var fetched = await repo.GetAsync(tenant, issuerId, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Contact.Email.Should().Be("security@contoso.test");
fetched.Metadata.Attributes.Should().ContainKey("tier");
fetched.Tags.Should().Contain("vendor");
fetched.Endpoints.Should().ContainSingle().Which.RequiresAuthentication.Should().BeTrue();
}
}

View File

@@ -0,0 +1,21 @@
<?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.IssuerDirectory.Persistence.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.IssuerDirectory.Persistence\StellaOps.IssuerDirectory.Persistence.csproj" />
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="..\..\StellaOps.IssuerDirectory\StellaOps.IssuerDirectory.Core\StellaOps.IssuerDirectory.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
# StellaOps.IssuerDirectory.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-0377-M | DONE | Revalidated 2026-01-07; maintainability audit for IssuerDirectory.Persistence.Tests. |
| AUDIT-0377-T | DONE | Revalidated 2026-01-07; test coverage audit for IssuerDirectory.Persistence.Tests. |
| AUDIT-0377-A | DONE | Waived (test project; revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-07 | DONE | 2026-02-04: Split audit sink tests into partials, sorted usings, added DI registration unit tests and new coverage (SPRINT_20260130_002). |

View File

@@ -0,0 +1,250 @@
// -----------------------------------------------------------------------------
// TenantIsolationTests.cs
// Module: IssuerDirectory
// Description: Unit tests verifying the IssuerDirectory module's header-based
// tenant resolution contract. The production TenantResolver is
// internal to the WebService assembly; these tests exercise the
// same header-reading contract using a local test helper that
// mirrors the documented behaviour from:
// src/IssuerDirectory/StellaOps.IssuerDirectory/
// StellaOps.IssuerDirectory.WebService/Services/TenantResolver.cs
//
// The IssuerDirectory TenantResolver reads a configurable HTTP header
// (default: "X-StellaOps-Tenant") and:
// - Returns false with an error when the header is missing or empty/whitespace.
// - Returns true with the trimmed tenant ID when the header is present and non-empty.
// - The legacy Resolve() method throws InvalidOperationException on failure.
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Xunit;
namespace StellaOps.IssuerDirectory.Persistence.Tests;
/// <summary>
/// Tenant isolation tests for the IssuerDirectory module's header-based
/// <c>TenantResolver</c>. Pure unit tests -- no Postgres, no WebApplicationFactory.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TenantIsolationTests
{
/// <summary>
/// The default tenant header used by the IssuerDirectory module.
/// Mirrors <c>IssuerDirectoryWebServiceOptions.TenantHeader</c> default value.
/// </summary>
private const string DefaultTenantHeader = "X-StellaOps-Tenant";
// ---------------------------------------------------------------
// 1. Missing header returns false
// ---------------------------------------------------------------
[Fact]
public void TryResolve_MissingHeader_ReturnsFalse()
{
// Arrange -- no tenant header present
var ctx = CreateHttpContext();
var resolver = CreateResolver(DefaultTenantHeader);
// Act
var resolved = resolver.TryResolve(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeFalse("no tenant header is present");
tenantId.Should().BeEmpty();
error.Should().Contain(DefaultTenantHeader, "error should reference the expected header name");
}
// ---------------------------------------------------------------
// 2. Empty header returns false
// ---------------------------------------------------------------
[Fact]
public void TryResolve_EmptyHeader_ReturnsFalse()
{
// Arrange -- header is present but empty
var ctx = CreateHttpContext();
ctx.Request.Headers[DefaultTenantHeader] = string.Empty;
var resolver = CreateResolver(DefaultTenantHeader);
// Act
var resolved = resolver.TryResolve(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeFalse("empty header value should be rejected");
tenantId.Should().BeEmpty();
error.Should().NotBeNullOrWhiteSpace();
}
// ---------------------------------------------------------------
// 3. Valid header returns true with tenant ID
// ---------------------------------------------------------------
[Fact]
public void TryResolve_ValidHeader_ReturnsTrue()
{
// Arrange
var ctx = CreateHttpContext();
ctx.Request.Headers[DefaultTenantHeader] = "acme-corp";
var resolver = CreateResolver(DefaultTenantHeader);
// Act
var resolved = resolver.TryResolve(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeTrue();
tenantId.Should().Be("acme-corp");
error.Should().BeEmpty();
}
// ---------------------------------------------------------------
// 4. Whitespace-only header returns false
// ---------------------------------------------------------------
[Fact]
public void TryResolve_WhitespaceHeader_ReturnsFalse()
{
// Arrange -- header value is whitespace only
var ctx = CreateHttpContext();
ctx.Request.Headers[DefaultTenantHeader] = " ";
var resolver = CreateResolver(DefaultTenantHeader);
// Act
var resolved = resolver.TryResolve(ctx, out var tenantId, out var error);
// Assert
resolved.Should().BeFalse("whitespace-only header value should be rejected");
tenantId.Should().BeEmpty();
error.Should().NotBeNullOrWhiteSpace();
}
// ---------------------------------------------------------------
// 5. Legacy Resolve() throws on missing header
// ---------------------------------------------------------------
[Fact]
public void Resolve_MissingHeader_ThrowsInvalidOperation()
{
// Arrange -- no tenant header
var ctx = CreateHttpContext();
var resolver = CreateResolver(DefaultTenantHeader);
// Act
var act = () => resolver.Resolve(ctx);
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage($"*{DefaultTenantHeader}*");
}
// ---------------------------------------------------------------
// 6. Legacy Resolve() returns tenant ID on valid header
// ---------------------------------------------------------------
[Fact]
public void Resolve_ValidHeader_ReturnsTenantId()
{
// Arrange
var ctx = CreateHttpContext();
ctx.Request.Headers[DefaultTenantHeader] = " beta-tenant ";
var resolver = CreateResolver(DefaultTenantHeader);
// Act
var tenantId = resolver.Resolve(ctx);
// Assert
tenantId.Should().Be("beta-tenant", "the resolver should trim whitespace");
}
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
private static DefaultHttpContext CreateHttpContext()
{
var ctx = new DefaultHttpContext();
ctx.Response.Body = new MemoryStream();
return ctx;
}
/// <summary>
/// Creates a test-local tenant resolver that mirrors the exact contract of
/// <c>IssuerDirectory.WebService.Services.TenantResolver</c>.
/// The production class is <c>internal sealed</c> and not accessible from
/// this test assembly; this local helper replicates the documented behaviour
/// so the tests validate the same header-based resolution contract.
/// </summary>
private static TestTenantResolver CreateResolver(string tenantHeader)
{
return new TestTenantResolver(tenantHeader);
}
/// <summary>
/// Local mirror of the IssuerDirectory TenantResolver contract.
/// Replicates the exact logic from:
/// <c>src/IssuerDirectory/StellaOps.IssuerDirectory/
/// StellaOps.IssuerDirectory.WebService/Services/TenantResolver.cs</c>
/// </summary>
private sealed class TestTenantResolver
{
private readonly string _tenantHeader;
public TestTenantResolver(string tenantHeader)
{
_tenantHeader = tenantHeader ?? throw new ArgumentNullException(nameof(tenantHeader));
}
/// <summary>
/// Resolves the tenant identifier from the configured HTTP header.
/// Throws <see cref="InvalidOperationException"/> when the header is missing or empty.
/// </summary>
public string Resolve(HttpContext context)
{
ArgumentNullException.ThrowIfNull(context);
if (!context.Request.Headers.TryGetValue(_tenantHeader, out var values))
{
throw new InvalidOperationException(
$"Tenant header '{_tenantHeader}' is required for Issuer Directory operations.");
}
var tenantId = values.ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
throw new InvalidOperationException(
$"Tenant header '{_tenantHeader}' must contain a value.");
}
return tenantId.Trim();
}
/// <summary>
/// Attempts to resolve the tenant identifier from the configured HTTP header.
/// Returns <c>false</c> with a deterministic error message when the header
/// is missing or empty.
/// </summary>
public bool TryResolve(HttpContext context, out string tenantId, out string error)
{
ArgumentNullException.ThrowIfNull(context);
tenantId = string.Empty;
error = string.Empty;
if (!context.Request.Headers.TryGetValue(_tenantHeader, out var values))
{
error = $"Missing required header '{_tenantHeader}'.";
return false;
}
var raw = values.ToString();
if (string.IsNullOrWhiteSpace(raw))
{
error = $"Header '{_tenantHeader}' must contain a non-empty value.";
return false;
}
tenantId = raw.Trim();
return true;
}
}
}

View File

@@ -0,0 +1,107 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Persistence.Postgres;
using StellaOps.IssuerDirectory.Persistence.Postgres.Repositories;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.IssuerDirectory.Persistence.Postgres.Tests;
public class TrustRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixture>
{
private readonly IssuerDirectoryPostgresFixture _fixture;
public TrustRepositoryTests(IssuerDirectoryPostgresFixture fixture)
{
_fixture = fixture;
}
private PostgresIssuerRepository CreateIssuerRepo() =>
new(new IssuerDirectoryDataSource(Options.Create(_fixture.Fixture.CreateOptions()), NullLogger<IssuerDirectoryDataSource>.Instance),
NullLogger<PostgresIssuerRepository>.Instance);
private PostgresIssuerTrustRepository CreateTrustRepo() =>
new(new IssuerDirectoryDataSource(Options.Create(_fixture.Fixture.CreateOptions()), NullLogger<IssuerDirectoryDataSource>.Instance),
NullLogger<PostgresIssuerTrustRepository>.Instance);
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task UpsertTrustOverride_WorksAsync()
{
var tenant = Guid.NewGuid().ToString();
var issuerId = Guid.NewGuid().ToString();
var issuerRepo = CreateIssuerRepo();
var trustRepo = CreateTrustRepo();
var timestamp = DateTimeOffset.UtcNow;
var issuer = IssuerRecord.Create(
id: issuerId,
tenantId: tenant,
displayName: "Trusty Issuer",
slug: "trusty",
description: null,
contact: new IssuerContact(null, null, null, null),
metadata: new IssuerMetadata(null, null, null, null, null, null),
endpoints: null,
tags: null,
timestampUtc: timestamp,
actor: "test",
isSystemSeed: false);
await issuerRepo.UpsertAsync(issuer, CancellationToken.None);
var trust = IssuerTrustOverrideRecord.Create(
issuerId: issuerId,
tenantId: tenant,
weight: 0.75m,
reason: "vendor override",
timestampUtc: DateTimeOffset.UtcNow,
actor: "test");
await trustRepo.UpsertAsync(trust, CancellationToken.None);
var fetched = await trustRepo.GetAsync(tenant, issuerId, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Weight.Should().Be(0.75m);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Delete_RemovesTrustOverrideAsync()
{
var tenant = Guid.NewGuid().ToString();
var issuerId = Guid.NewGuid().ToString();
var issuerRepo = CreateIssuerRepo();
var trustRepo = CreateTrustRepo();
var issuer = IssuerRecord.Create(
id: issuerId,
tenantId: tenant,
displayName: "Delete Issuer",
slug: "delete-issuer",
description: null,
contact: new IssuerContact(null, null, null, null),
metadata: new IssuerMetadata(null, null, null, null, null, null),
endpoints: null,
tags: null,
timestampUtc: DateTimeOffset.Parse("2026-01-03T00:00:00Z"),
actor: "test",
isSystemSeed: false);
await issuerRepo.UpsertAsync(issuer, CancellationToken.None);
var trust = IssuerTrustOverrideRecord.Create(
issuerId: issuerId,
tenantId: tenant,
weight: 0.2m,
reason: null,
timestampUtc: DateTimeOffset.Parse("2026-01-03T00:10:00Z"),
actor: "test");
await trustRepo.UpsertAsync(trust, CancellationToken.None);
await trustRepo.DeleteAsync(tenant, issuerId, CancellationToken.None);
var fetched = await trustRepo.GetAsync(tenant, issuerId, CancellationToken.None);
fetched.Should().BeNull();
}
}