consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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). |
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user