Add unit tests and logging infrastructure for InMemory and RabbitMQ transports
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented RecordingLogger and RecordingLoggerFactory for capturing log entries in tests.
- Added unit tests for InMemoryChannel, covering constructor behavior, property assignments, channel communication, and disposal.
- Created InMemoryTransportOptionsTests to validate default values and customizable options for InMemory transport.
- Developed RabbitMqFrameProtocolTests to ensure correct parsing and property creation for RabbitMQ frames.
- Added RabbitMqTransportOptionsTests to verify default settings and customization options for RabbitMQ transport.
- Updated project files for testing libraries and dependencies.
This commit is contained in:
StellaOps Bot
2025-12-05 09:38:45 +02:00
parent 6a299d231f
commit 53508ceccb
98 changed files with 10868 additions and 663 deletions

View File

@@ -0,0 +1,65 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of the issuer audit sink.
/// </summary>
public sealed class PostgresIssuerAuditSink : IIssuerAuditSink
{
private readonly IssuerDirectoryDataSource _dataSource;
private readonly ILogger<PostgresIssuerAuditSink> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public PostgresIssuerAuditSink(IssuerDirectoryDataSource dataSource, ILogger<PostgresIssuerAuditSink> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task WriteAsync(IssuerAuditEntry entry, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(entry);
await using var connection = await _dataSource.OpenConnectionAsync(entry.TenantId, "writer", cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO issuer.audit (tenant_id, actor, action, issuer_id, reason, details, occurred_at)
VALUES (@tenantId::uuid, @actor, @action, @issuerId::uuid, @reason, @details::jsonb, @occurredAt)
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", Guid.Parse(entry.TenantId));
command.Parameters.AddWithValue("actor", entry.Actor);
command.Parameters.AddWithValue("action", entry.Action);
command.Parameters.AddWithValue("issuerId", Guid.Parse(entry.IssuerId));
command.Parameters.Add(new NpgsqlParameter("reason", NpgsqlDbType.Text) { Value = entry.Reason ?? (object)DBNull.Value });
command.Parameters.Add(new NpgsqlParameter("details", NpgsqlDbType.Jsonb) { Value = SerializeMetadata(entry.Metadata) });
command.Parameters.AddWithValue("occurredAt", entry.TimestampUtc.UtcDateTime);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Wrote audit entry: {Action} for issuer {IssuerId} by {Actor}.", entry.Action, entry.IssuerId, entry.Actor);
}
private static string SerializeMetadata(IReadOnlyDictionary<string, string> metadata)
{
if (metadata.Count == 0)
{
return "{}";
}
return JsonSerializer.Serialize(metadata, JsonOptions);
}
}

View File

@@ -0,0 +1,241 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of the issuer key repository.
/// </summary>
public sealed class PostgresIssuerKeyRepository : IIssuerKeyRepository
{
private readonly IssuerDirectoryDataSource _dataSource;
private readonly ILogger<PostgresIssuerKeyRepository> _logger;
public PostgresIssuerKeyRepository(IssuerDirectoryDataSource dataSource, ILogger<PostgresIssuerKeyRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IssuerKeyRecord?> GetAsync(string tenantId, string issuerId, string keyId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status, replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata
FROM issuer.issuer_keys
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid AND key_id = @keyId
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
command.Parameters.AddWithValue("keyId", keyId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapToRecord(reader);
}
public async Task<IssuerKeyRecord?> GetByFingerprintAsync(string tenantId, string issuerId, string fingerprint, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status, replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata
FROM issuer.issuer_keys
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid AND fingerprint = @fingerprint
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
command.Parameters.AddWithValue("fingerprint", fingerprint);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapToRecord(reader);
}
public async Task<IReadOnlyCollection<IssuerKeyRecord>> ListAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status, replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata
FROM issuer.issuer_keys
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
ORDER BY created_at ASC
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyCollection<IssuerKeyRecord>> ListGlobalAsync(string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status, replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata
FROM issuer.issuer_keys
WHERE tenant_id = @globalTenantId::uuid AND issuer_id = @issuerId::uuid
ORDER BY created_at ASC
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("globalTenantId", IssuerTenants.Global);
command.Parameters.AddWithValue("issuerId", issuerId);
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
}
public async Task UpsertAsync(IssuerKeyRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
await using var connection = await _dataSource.OpenConnectionAsync(record.TenantId, "writer", cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO issuer.issuer_keys (id, issuer_id, tenant_id, key_id, key_type, public_key, fingerprint, not_before, not_after, status, replaces_key_id, created_at, created_by, updated_at, updated_by, retired_at, revoked_at, revoke_reason, metadata)
VALUES (@id::uuid, @issuerId::uuid, @tenantId::uuid, @keyId, @keyType, @publicKey, @fingerprint, @notBefore, @notAfter, @status, @replacesKeyId, @createdAt, @createdBy, @updatedAt, @updatedBy, @retiredAt, @revokedAt, @revokeReason, @metadata::jsonb)
ON CONFLICT (issuer_id, key_id)
DO UPDATE SET
key_type = EXCLUDED.key_type,
public_key = EXCLUDED.public_key,
fingerprint = EXCLUDED.fingerprint,
not_before = EXCLUDED.not_before,
not_after = EXCLUDED.not_after,
status = EXCLUDED.status,
replaces_key_id = EXCLUDED.replaces_key_id,
updated_at = EXCLUDED.updated_at,
updated_by = EXCLUDED.updated_by,
retired_at = EXCLUDED.retired_at,
revoked_at = EXCLUDED.revoked_at,
revoke_reason = EXCLUDED.revoke_reason,
metadata = EXCLUDED.metadata
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("id", Guid.Parse(record.Id));
command.Parameters.AddWithValue("issuerId", Guid.Parse(record.IssuerId));
command.Parameters.AddWithValue("tenantId", Guid.Parse(record.TenantId));
command.Parameters.AddWithValue("keyId", record.Id);
command.Parameters.AddWithValue("keyType", MapKeyType(record.Type));
command.Parameters.AddWithValue("publicKey", record.Material.Value);
command.Parameters.AddWithValue("fingerprint", record.Fingerprint);
command.Parameters.Add(new NpgsqlParameter("notBefore", NpgsqlDbType.TimestampTz) { Value = (object?)null ?? DBNull.Value });
command.Parameters.Add(new NpgsqlParameter("notAfter", NpgsqlDbType.TimestampTz) { Value = record.ExpiresAtUtc.HasValue ? record.ExpiresAtUtc.Value.UtcDateTime : DBNull.Value });
command.Parameters.AddWithValue("status", record.Status.ToString().ToLowerInvariant());
command.Parameters.Add(new NpgsqlParameter("replacesKeyId", NpgsqlDbType.Text) { Value = record.ReplacesKeyId ?? (object)DBNull.Value });
command.Parameters.AddWithValue("createdAt", record.CreatedAtUtc.UtcDateTime);
command.Parameters.AddWithValue("createdBy", record.CreatedBy);
command.Parameters.AddWithValue("updatedAt", record.UpdatedAtUtc.UtcDateTime);
command.Parameters.AddWithValue("updatedBy", record.UpdatedBy);
command.Parameters.Add(new NpgsqlParameter("retiredAt", NpgsqlDbType.TimestampTz) { Value = record.RetiredAtUtc.HasValue ? record.RetiredAtUtc.Value.UtcDateTime : DBNull.Value });
command.Parameters.Add(new NpgsqlParameter("revokedAt", NpgsqlDbType.TimestampTz) { Value = record.RevokedAtUtc.HasValue ? record.RevokedAtUtc.Value.UtcDateTime : DBNull.Value });
command.Parameters.Add(new NpgsqlParameter("revokeReason", NpgsqlDbType.Text) { Value = DBNull.Value });
command.Parameters.Add(new NpgsqlParameter("metadata", NpgsqlDbType.Jsonb) { Value = "{}" });
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Upserted issuer key {KeyId} for issuer {IssuerId}.", record.Id, record.IssuerId);
}
private static async Task<IReadOnlyCollection<IssuerKeyRecord>> ReadAllRecordsAsync(NpgsqlCommand command, CancellationToken cancellationToken)
{
var results = new List<IssuerKeyRecord>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapToRecord(reader));
}
return results;
}
private static IssuerKeyRecord MapToRecord(NpgsqlDataReader reader)
{
var id = reader.GetGuid(0).ToString();
var issuerId = reader.GetGuid(1).ToString();
var tenantId = reader.GetGuid(2).ToString();
var keyId = reader.GetString(3);
var keyType = ParseKeyType(reader.GetString(4));
var publicKey = reader.GetString(5);
var fingerprint = reader.GetString(6);
var notBefore = reader.IsDBNull(7) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(7), TimeSpan.Zero);
var notAfter = reader.IsDBNull(8) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(8), TimeSpan.Zero);
var status = ParseKeyStatus(reader.GetString(9));
var replacesKeyId = reader.IsDBNull(10) ? null : reader.GetString(10);
var createdAt = reader.GetDateTime(11);
var createdBy = reader.GetString(12);
var updatedAt = reader.GetDateTime(13);
var updatedBy = reader.GetString(14);
var retiredAt = reader.IsDBNull(15) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(15), TimeSpan.Zero);
var revokedAt = reader.IsDBNull(16) ? (DateTimeOffset?)null : new DateTimeOffset(reader.GetDateTime(16), TimeSpan.Zero);
return new IssuerKeyRecord
{
Id = keyId,
IssuerId = issuerId,
TenantId = tenantId,
Type = keyType,
Status = status,
Material = new IssuerKeyMaterial("pem", publicKey),
Fingerprint = fingerprint,
CreatedAtUtc = new DateTimeOffset(createdAt, TimeSpan.Zero),
CreatedBy = createdBy,
UpdatedAtUtc = new DateTimeOffset(updatedAt, TimeSpan.Zero),
UpdatedBy = updatedBy,
ExpiresAtUtc = notAfter,
RetiredAtUtc = retiredAt,
RevokedAtUtc = revokedAt,
ReplacesKeyId = replacesKeyId
};
}
private static string MapKeyType(IssuerKeyType type) => type switch
{
IssuerKeyType.Ed25519PublicKey => "ed25519",
IssuerKeyType.X509Certificate => "x509",
IssuerKeyType.DssePublicKey => "dsse",
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unsupported key type")
};
private static IssuerKeyType ParseKeyType(string value) => value.ToLowerInvariant() switch
{
"ed25519" => IssuerKeyType.Ed25519PublicKey,
"x509" => IssuerKeyType.X509Certificate,
"dsse" => IssuerKeyType.DssePublicKey,
_ => throw new ArgumentException($"Unknown key type: {value}", nameof(value))
};
private static IssuerKeyStatus ParseKeyStatus(string value) => value.ToLowerInvariant() switch
{
"active" => IssuerKeyStatus.Active,
"retired" => IssuerKeyStatus.Retired,
"revoked" => IssuerKeyStatus.Revoked,
_ => throw new ArgumentException($"Unknown key status: {value}", nameof(value))
};
}

View File

@@ -0,0 +1,317 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of the issuer repository.
/// </summary>
public sealed class PostgresIssuerRepository : IIssuerRepository
{
private readonly IssuerDirectoryDataSource _dataSource;
private readonly ILogger<PostgresIssuerRepository> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public PostgresIssuerRepository(IssuerDirectoryDataSource dataSource, ILogger<PostgresIssuerRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IssuerRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status, is_system_seed, created_at, created_by, updated_at, updated_by
FROM issuer.issuers
WHERE tenant_id = @tenantId::uuid AND id = @issuerId::uuid
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapToRecord(reader);
}
public async Task<IReadOnlyCollection<IssuerRecord>> ListAsync(string tenantId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status, is_system_seed, created_at, created_by, updated_at, updated_by
FROM issuer.issuers
WHERE tenant_id = @tenantId::uuid
ORDER BY name ASC
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyCollection<IssuerRecord>> ListGlobalAsync(CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status, is_system_seed, created_at, created_by, updated_at, updated_by
FROM issuer.issuers
WHERE tenant_id = @globalTenantId::uuid
ORDER BY name ASC
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("globalTenantId", IssuerTenants.Global);
return await ReadAllRecordsAsync(command, cancellationToken).ConfigureAwait(false);
}
public async Task UpsertAsync(IssuerRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
await using var connection = await _dataSource.OpenConnectionAsync(record.TenantId, "writer", cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO issuer.issuers (id, tenant_id, name, display_name, description, endpoints, contact, metadata, tags, status, is_system_seed, created_at, created_by, updated_at, updated_by)
VALUES (@id::uuid, @tenantId::uuid, @name, @displayName, @description, @endpoints::jsonb, @contact::jsonb, @metadata::jsonb, @tags, @status, @isSystemSeed, @createdAt, @createdBy, @updatedAt, @updatedBy)
ON CONFLICT (tenant_id, name)
DO UPDATE SET
display_name = EXCLUDED.display_name,
description = EXCLUDED.description,
endpoints = EXCLUDED.endpoints,
contact = EXCLUDED.contact,
metadata = EXCLUDED.metadata,
tags = EXCLUDED.tags,
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at,
updated_by = EXCLUDED.updated_by
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("id", Guid.Parse(record.Id));
command.Parameters.AddWithValue("tenantId", Guid.Parse(record.TenantId));
command.Parameters.AddWithValue("name", record.Slug);
command.Parameters.AddWithValue("displayName", record.DisplayName);
command.Parameters.Add(new NpgsqlParameter("description", NpgsqlDbType.Text) { Value = record.Description ?? (object)DBNull.Value });
command.Parameters.Add(new NpgsqlParameter("endpoints", NpgsqlDbType.Jsonb) { Value = SerializeEndpoints(record.Endpoints) });
command.Parameters.Add(new NpgsqlParameter("contact", NpgsqlDbType.Jsonb) { Value = SerializeContact(record.Contact) });
command.Parameters.Add(new NpgsqlParameter("metadata", NpgsqlDbType.Jsonb) { Value = SerializeMetadata(record.Metadata) });
command.Parameters.Add(new NpgsqlParameter("tags", NpgsqlDbType.Array | NpgsqlDbType.Text) { Value = record.Tags.ToArray() });
command.Parameters.AddWithValue("status", "active");
command.Parameters.AddWithValue("isSystemSeed", record.IsSystemSeed);
command.Parameters.AddWithValue("createdAt", record.CreatedAtUtc);
command.Parameters.AddWithValue("createdBy", record.CreatedBy);
command.Parameters.AddWithValue("updatedAt", record.UpdatedAtUtc);
command.Parameters.AddWithValue("updatedBy", record.UpdatedBy);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Upserted issuer {IssuerId} for tenant {TenantId}.", record.Id, record.TenantId);
}
public async Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
const string sql = "DELETE FROM issuer.issuers WHERE tenant_id = @tenantId::uuid AND id = @issuerId::uuid";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Deleted issuer {IssuerId} for tenant {TenantId}. Rows affected: {Rows}.", issuerId, tenantId, rowsAffected);
}
private static async Task<IReadOnlyCollection<IssuerRecord>> ReadAllRecordsAsync(NpgsqlCommand command, CancellationToken cancellationToken)
{
var results = new List<IssuerRecord>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapToRecord(reader));
}
return results;
}
private static IssuerRecord MapToRecord(NpgsqlDataReader reader)
{
var id = reader.GetGuid(0).ToString();
var tenantId = reader.GetGuid(1).ToString();
var name = reader.GetString(2);
var displayName = reader.GetString(3);
var description = reader.IsDBNull(4) ? null : reader.GetString(4);
var endpointsJson = reader.GetString(5);
var contactJson = reader.GetString(6);
var metadataJson = reader.GetString(7);
var tags = reader.GetFieldValue<string[]>(8);
var isSystemSeed = reader.GetBoolean(10);
var createdAt = reader.GetDateTime(11);
var createdBy = reader.GetString(12);
var updatedAt = reader.GetDateTime(13);
var updatedBy = reader.GetString(14);
var contact = DeserializeContact(contactJson);
var metadata = DeserializeMetadata(metadataJson);
var endpoints = DeserializeEndpoints(endpointsJson);
return new IssuerRecord
{
Id = id,
TenantId = tenantId,
Slug = name,
DisplayName = displayName,
Description = description,
Contact = contact,
Metadata = metadata,
Endpoints = endpoints,
Tags = tags,
IsSystemSeed = isSystemSeed,
CreatedAtUtc = new DateTimeOffset(createdAt, TimeSpan.Zero),
CreatedBy = createdBy,
UpdatedAtUtc = new DateTimeOffset(updatedAt, TimeSpan.Zero),
UpdatedBy = updatedBy
};
}
private static string SerializeContact(IssuerContact contact)
{
var doc = new
{
email = contact.Email,
phone = contact.Phone,
website = contact.Website?.ToString(),
timezone = contact.Timezone
};
return JsonSerializer.Serialize(doc, JsonOptions);
}
private static IssuerContact DeserializeContact(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var email = root.TryGetProperty("email", out var e) && e.ValueKind != JsonValueKind.Null ? e.GetString() : null;
var phone = root.TryGetProperty("phone", out var p) && p.ValueKind != JsonValueKind.Null ? p.GetString() : null;
var websiteStr = root.TryGetProperty("website", out var w) && w.ValueKind != JsonValueKind.Null ? w.GetString() : null;
var timezone = root.TryGetProperty("timezone", out var t) && t.ValueKind != JsonValueKind.Null ? t.GetString() : null;
return new IssuerContact(email, phone, string.IsNullOrWhiteSpace(websiteStr) ? null : new Uri(websiteStr), timezone);
}
private static string SerializeMetadata(IssuerMetadata metadata)
{
var doc = new
{
cveOrgId = metadata.CveOrgId,
csafPublisherId = metadata.CsafPublisherId,
securityAdvisoriesUrl = metadata.SecurityAdvisoriesUrl?.ToString(),
catalogUrl = metadata.CatalogUrl?.ToString(),
languages = metadata.SupportedLanguages.ToList(),
attributes = new Dictionary<string, string>(metadata.Attributes)
};
return JsonSerializer.Serialize(doc, JsonOptions);
}
private static IssuerMetadata DeserializeMetadata(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var cveOrgId = root.TryGetProperty("cveOrgId", out var c) && c.ValueKind != JsonValueKind.Null ? c.GetString() : null;
var csafPublisherId = root.TryGetProperty("csafPublisherId", out var cp) && cp.ValueKind != JsonValueKind.Null ? cp.GetString() : null;
var securityAdvisoriesUrlStr = root.TryGetProperty("securityAdvisoriesUrl", out var sa) && sa.ValueKind != JsonValueKind.Null ? sa.GetString() : null;
var catalogUrlStr = root.TryGetProperty("catalogUrl", out var cu) && cu.ValueKind != JsonValueKind.Null ? cu.GetString() : null;
var languages = new List<string>();
if (root.TryGetProperty("languages", out var langs) && langs.ValueKind == JsonValueKind.Array)
{
foreach (var lang in langs.EnumerateArray())
{
if (lang.ValueKind == JsonValueKind.String)
{
languages.Add(lang.GetString()!);
}
}
}
var attributes = new Dictionary<string, string>();
if (root.TryGetProperty("attributes", out var attrs) && attrs.ValueKind == JsonValueKind.Object)
{
foreach (var prop in attrs.EnumerateObject())
{
if (prop.Value.ValueKind == JsonValueKind.String)
{
attributes[prop.Name] = prop.Value.GetString()!;
}
}
}
return new IssuerMetadata(
cveOrgId,
csafPublisherId,
string.IsNullOrWhiteSpace(securityAdvisoriesUrlStr) ? null : new Uri(securityAdvisoriesUrlStr),
string.IsNullOrWhiteSpace(catalogUrlStr) ? null : new Uri(catalogUrlStr),
languages,
attributes);
}
private static string SerializeEndpoints(IReadOnlyCollection<IssuerEndpoint> endpoints)
{
var docs = endpoints.Select(e => new
{
kind = e.Kind,
url = e.Url.ToString(),
format = e.Format,
requiresAuthentication = e.RequiresAuthentication
}).ToList();
return JsonSerializer.Serialize(docs, JsonOptions);
}
private static IReadOnlyCollection<IssuerEndpoint> DeserializeEndpoints(string json)
{
var results = new List<IssuerEndpoint>();
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array)
{
return results;
}
foreach (var elem in doc.RootElement.EnumerateArray())
{
var kind = elem.TryGetProperty("kind", out var k) ? k.GetString() : null;
var urlStr = elem.TryGetProperty("url", out var u) ? u.GetString() : null;
var format = elem.TryGetProperty("format", out var f) && f.ValueKind != JsonValueKind.Null ? f.GetString() : null;
var requiresAuth = elem.TryGetProperty("requiresAuthentication", out var ra) && ra.GetBoolean();
if (!string.IsNullOrWhiteSpace(kind) && !string.IsNullOrWhiteSpace(urlStr))
{
results.Add(new IssuerEndpoint(kind, new Uri(urlStr), format, requiresAuth));
}
}
return results;
}
}

View File

@@ -0,0 +1,120 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
namespace StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL implementation of the issuer trust repository.
/// </summary>
public sealed class PostgresIssuerTrustRepository : IIssuerTrustRepository
{
private readonly IssuerDirectoryDataSource _dataSource;
private readonly ILogger<PostgresIssuerTrustRepository> _logger;
public PostgresIssuerTrustRepository(IssuerDirectoryDataSource dataSource, ILogger<PostgresIssuerTrustRepository> logger)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IssuerTrustOverrideRecord?> GetAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
SELECT id, issuer_id, tenant_id, weight, rationale, expires_at, created_at, created_by, updated_at, updated_by
FROM issuer.trust_overrides
WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid
LIMIT 1
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return MapToRecord(reader);
}
public async Task UpsertAsync(IssuerTrustOverrideRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
await using var connection = await _dataSource.OpenConnectionAsync(record.TenantId, "writer", cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO issuer.trust_overrides (issuer_id, tenant_id, weight, rationale, created_at, created_by, updated_at, updated_by)
VALUES (@issuerId::uuid, @tenantId::uuid, @weight, @rationale, @createdAt, @createdBy, @updatedAt, @updatedBy)
ON CONFLICT (issuer_id, tenant_id)
DO UPDATE SET
weight = EXCLUDED.weight,
rationale = EXCLUDED.rationale,
updated_at = EXCLUDED.updated_at,
updated_by = EXCLUDED.updated_by
""";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("issuerId", Guid.Parse(record.IssuerId));
command.Parameters.AddWithValue("tenantId", Guid.Parse(record.TenantId));
command.Parameters.AddWithValue("weight", record.Weight);
command.Parameters.Add(new NpgsqlParameter("rationale", NpgsqlDbType.Text) { Value = record.Reason ?? (object)DBNull.Value });
command.Parameters.AddWithValue("createdAt", record.CreatedAtUtc.UtcDateTime);
command.Parameters.AddWithValue("createdBy", record.CreatedBy);
command.Parameters.AddWithValue("updatedAt", record.UpdatedAtUtc.UtcDateTime);
command.Parameters.AddWithValue("updatedBy", record.UpdatedBy);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Upserted trust override for issuer {IssuerId} in tenant {TenantId}.", record.IssuerId, record.TenantId);
}
public async Task DeleteAsync(string tenantId, string issuerId, CancellationToken cancellationToken)
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
const string sql = "DELETE FROM issuer.trust_overrides WHERE tenant_id = @tenantId::uuid AND issuer_id = @issuerId::uuid";
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
command.Parameters.AddWithValue("tenantId", tenantId);
command.Parameters.AddWithValue("issuerId", issuerId);
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Deleted trust override for issuer {IssuerId} in tenant {TenantId}. Rows affected: {Rows}.", issuerId, tenantId, rowsAffected);
}
private static IssuerTrustOverrideRecord MapToRecord(NpgsqlDataReader reader)
{
var issuerId = reader.GetGuid(1).ToString();
var tenantId = reader.GetGuid(2).ToString();
var weight = reader.GetDecimal(3);
var rationale = reader.IsDBNull(4) ? null : reader.GetString(4);
var createdAt = reader.GetDateTime(6);
var createdBy = reader.GetString(7);
var updatedAt = reader.GetDateTime(8);
var updatedBy = reader.GetString(9);
return new IssuerTrustOverrideRecord
{
IssuerId = issuerId,
TenantId = tenantId,
Weight = weight,
Reason = rationale,
CreatedAtUtc = new DateTimeOffset(createdAt, TimeSpan.Zero),
CreatedBy = createdBy,
UpdatedAtUtc = new DateTimeOffset(updatedAt, TimeSpan.Zero),
UpdatedBy = updatedBy
};
}
}

View File

@@ -1,6 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.IssuerDirectory.Core.Abstractions;
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
namespace StellaOps.IssuerDirectory.Storage.Postgres;
@@ -34,6 +36,8 @@ public static class ServiceCollectionExtensions
return new IssuerDirectoryDataSource(options, logger);
});
RegisterRepositories(services);
return services;
}
@@ -61,6 +65,16 @@ public static class ServiceCollectionExtensions
return new IssuerDirectoryDataSource(options, logger);
});
RegisterRepositories(services);
return services;
}
private static void RegisterRepositories(IServiceCollection services)
{
services.AddScoped<IIssuerRepository, PostgresIssuerRepository>();
services.AddScoped<IIssuerKeyRepository, PostgresIssuerKeyRepository>();
services.AddScoped<IIssuerTrustRepository, PostgresIssuerTrustRepository>();
services.AddScoped<IIssuerAuditSink, PostgresIssuerAuditSink>();
}
}

View File

@@ -10,6 +10,8 @@ public sealed class IssuerDirectoryWebServiceOptions
public AuthorityOptions Authority { get; set; } = new();
public PersistenceOptions Persistence { get; set; } = new();
public string TenantHeader { get; set; } = "X-StellaOps-Tenant";
public bool SeedCsafPublishers { get; set; } = true;
@@ -24,6 +26,7 @@ public sealed class IssuerDirectoryWebServiceOptions
}
Authority.Validate();
Persistence.Validate();
}
public sealed class TelemetryOptions
@@ -74,4 +77,31 @@ public sealed class IssuerDirectoryWebServiceOptions
}
}
}
public sealed class PersistenceOptions
{
/// <summary>
/// Storage provider for IssuerDirectory. Valid values: "Mongo", "Postgres".
/// </summary>
public string Provider { get; set; } = "Mongo";
/// <summary>
/// PostgreSQL connection string. Required when Provider is "Postgres".
/// </summary>
public string PostgresConnectionString { get; set; } = string.Empty;
public void Validate()
{
var normalized = Provider?.Trim().ToLowerInvariant() ?? string.Empty;
if (normalized != "mongo" && normalized != "postgres")
{
throw new InvalidOperationException($"IssuerDirectory persistence provider '{Provider}' is not supported. Use 'Mongo' or 'Postgres'.");
}
if (normalized == "postgres" && string.IsNullOrWhiteSpace(PostgresConnectionString))
{
throw new InvalidOperationException("PostgreSQL connection string is required when persistence provider is 'Postgres'.");
}
}
}
}

View File

@@ -14,7 +14,9 @@ using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.IssuerDirectory.Core.Services;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.IssuerDirectory.Infrastructure;
using StellaOps.IssuerDirectory.Storage.Postgres;
using StellaOps.IssuerDirectory.Infrastructure.Seed;
using StellaOps.IssuerDirectory.WebService.Endpoints;
using StellaOps.IssuerDirectory.WebService.Options;
@@ -58,7 +60,10 @@ builder.Services.AddOptions<IssuerDirectoryWebServiceOptions>()
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddProblemDetails();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddIssuerDirectoryInfrastructure(builder.Configuration);
// Configure persistence based on provider
ConfigurePersistence(builder, bootstrapOptions);
builder.Services.AddSingleton<IssuerDirectoryService>();
builder.Services.AddSingleton<IssuerKeyService>();
builder.Services.AddSingleton<IssuerTrustService>();
@@ -112,6 +117,28 @@ static LogEventLevel MapLogLevel(string? value)
: LogEventLevel.Information;
}
static void ConfigurePersistence(
WebApplicationBuilder builder,
IssuerDirectoryWebServiceOptions options)
{
var provider = options.Persistence.Provider?.Trim().ToLowerInvariant() ?? "mongo";
if (provider == "postgres")
{
Log.Information("Using PostgreSQL persistence for IssuerDirectory.");
builder.Services.AddIssuerDirectoryPostgresStorage(new PostgresOptions
{
ConnectionString = options.Persistence.PostgresConnectionString,
SchemaName = "issuer"
});
}
else
{
Log.Information("Using MongoDB persistence for IssuerDirectory.");
builder.Services.AddIssuerDirectoryInfrastructure(builder.Configuration);
}
}
static void ConfigureAuthentication(
WebApplicationBuilder builder,
IssuerDirectoryWebServiceOptions options)

View File

@@ -18,6 +18,7 @@
<ItemGroup>
<ProjectReference Include="..\\StellaOps.IssuerDirectory.Core\\StellaOps.IssuerDirectory.Core.csproj" />
<ProjectReference Include="..\\StellaOps.IssuerDirectory.Infrastructure\\StellaOps.IssuerDirectory.Infrastructure.csproj" />
<ProjectReference Include="..\\StellaOps.IssuerDirectory.Storage.Postgres\\StellaOps.IssuerDirectory.Storage.Postgres.csproj" />
<ProjectReference Include="..\\..\\..\\Authority\\StellaOps.Authority\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\\..\\..\\Authority\\StellaOps.Authority\\StellaOps.Auth.ServerIntegration\\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Configuration\\StellaOps.Configuration.csproj" />

View File

@@ -0,0 +1,248 @@
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
[Collection(IssuerDirectoryPostgresCollection.Name)]
public sealed 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, NullLogger<IssuerDirectoryDataSource>.Instance);
_issuerRepository = new PostgresIssuerRepository(_dataSource, NullLogger<PostgresIssuerRepository>.Instance);
_auditSink = new PostgresIssuerAuditSink(_dataSource, NullLogger<PostgresIssuerAuditSink>.Instance);
}
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
_issuerId = await SeedIssuerAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task WriteAsync_PersistsAuditEntry()
{
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");
}
[Fact]
public async Task WriteAsync_PersistsMetadata()
{
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");
}
[Fact]
public async Task WriteAsync_PersistsEmptyMetadata()
{
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();
}
[Fact]
public async Task WriteAsync_PersistsNullReason()
{
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();
}
[Fact]
public async Task WriteAsync_PersistsTimestampCorrectly()
{
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));
}
[Fact]
public async Task WriteAsync_PersistsMultipleEntriesForSameIssuer()
{
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);
}
[Fact]
public async Task WriteAsync_PersistsActorCorrectly()
{
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");
}
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);
}
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,28 @@
using System.Reflection;
using StellaOps.IssuerDirectory.Storage.Postgres;
using StellaOps.Infrastructure.Postgres.Testing;
using Xunit;
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
/// <summary>
/// PostgreSQL integration test fixture for the IssuerDirectory module.
/// Runs migrations from embedded resources and provides test isolation.
/// </summary>
public sealed class IssuerDirectoryPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<IssuerDirectoryPostgresFixture>
{
protected override Assembly? GetMigrationAssembly()
=> typeof(IssuerDirectoryDataSource).Assembly;
protected override string GetModuleName() => "IssuerDirectory";
}
/// <summary>
/// Collection definition for IssuerDirectory PostgreSQL integration tests.
/// Tests in this collection share a single PostgreSQL container instance.
/// </summary>
[CollectionDefinition(Name)]
public sealed class IssuerDirectoryPostgresCollection : ICollectionFixture<IssuerDirectoryPostgresFixture>
{
public const string Name = "IssuerDirectoryPostgres";
}

View File

@@ -0,0 +1,288 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
[Collection(IssuerDirectoryPostgresCollection.Name)]
public sealed class IssuerKeyRepositoryTests : IAsyncLifetime
{
private readonly IssuerDirectoryPostgresFixture _fixture;
private readonly PostgresIssuerRepository _issuerRepository;
private readonly PostgresIssuerKeyRepository _keyRepository;
private readonly string _tenantId = Guid.NewGuid().ToString();
private string _issuerId = null!;
public IssuerKeyRepositoryTests(IssuerDirectoryPostgresFixture fixture)
{
_fixture = fixture;
var options = new PostgresOptions
{
ConnectionString = fixture.ConnectionString,
SchemaName = fixture.SchemaName
};
var dataSource = new IssuerDirectoryDataSource(options, NullLogger<IssuerDirectoryDataSource>.Instance);
_issuerRepository = new PostgresIssuerRepository(dataSource, NullLogger<PostgresIssuerRepository>.Instance);
_keyRepository = new PostgresIssuerKeyRepository(dataSource, NullLogger<PostgresIssuerKeyRepository>.Instance);
}
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
_issuerId = await SeedIssuerAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task UpsertAsync_CreatesNewKey()
{
var keyRecord = CreateKeyRecord("key-001", IssuerKeyType.Ed25519PublicKey);
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "key-001", CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Id.Should().Be("key-001");
fetched.Type.Should().Be(IssuerKeyType.Ed25519PublicKey);
fetched.Status.Should().Be(IssuerKeyStatus.Active);
}
[Fact]
public async Task UpsertAsync_UpdatesExistingKey()
{
var keyRecord = CreateKeyRecord("key-update", IssuerKeyType.Ed25519PublicKey);
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
var updated = keyRecord with
{
Status = IssuerKeyStatus.Retired,
RetiredAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
UpdatedBy = "admin@test.com"
};
await _keyRepository.UpsertAsync(updated, CancellationToken.None);
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "key-update", CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Status.Should().Be(IssuerKeyStatus.Retired);
fetched.RetiredAtUtc.Should().NotBeNull();
}
[Fact]
public async Task GetAsync_ReturnsNullForNonExistentKey()
{
var result = await _keyRepository.GetAsync(_tenantId, _issuerId, "nonexistent", CancellationToken.None);
result.Should().BeNull();
}
[Fact]
public async Task GetByFingerprintAsync_ReturnsKey()
{
var fingerprint = $"fp_{Guid.NewGuid():N}";
var keyRecord = CreateKeyRecord("key-fp", IssuerKeyType.Ed25519PublicKey) with { Fingerprint = fingerprint };
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
var fetched = await _keyRepository.GetByFingerprintAsync(_tenantId, _issuerId, fingerprint, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Fingerprint.Should().Be(fingerprint);
}
[Fact]
public async Task ListAsync_ReturnsAllKeysForIssuer()
{
var key1 = CreateKeyRecord("key-list-1", IssuerKeyType.Ed25519PublicKey);
var key2 = CreateKeyRecord("key-list-2", IssuerKeyType.X509Certificate);
await _keyRepository.UpsertAsync(key1, CancellationToken.None);
await _keyRepository.UpsertAsync(key2, CancellationToken.None);
var results = await _keyRepository.ListAsync(_tenantId, _issuerId, CancellationToken.None);
results.Should().HaveCount(2);
results.Select(k => k.Id).Should().BeEquivalentTo(["key-list-1", "key-list-2"]);
}
[Fact]
public async Task ListGlobalAsync_ReturnsGlobalKeys()
{
var globalIssuerId = await SeedGlobalIssuerAsync();
var globalKey = CreateKeyRecord("global-key", IssuerKeyType.Ed25519PublicKey, globalIssuerId, IssuerTenants.Global);
await _keyRepository.UpsertAsync(globalKey, CancellationToken.None);
var results = await _keyRepository.ListGlobalAsync(globalIssuerId, CancellationToken.None);
results.Should().Contain(k => k.Id == "global-key");
}
[Fact]
public async Task UpsertAsync_PersistsKeyTypeEd25519()
{
var keyRecord = CreateKeyRecord("key-ed25519", IssuerKeyType.Ed25519PublicKey);
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "key-ed25519", CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Type.Should().Be(IssuerKeyType.Ed25519PublicKey);
}
[Fact]
public async Task UpsertAsync_PersistsKeyTypeX509()
{
var keyRecord = CreateKeyRecord("key-x509", IssuerKeyType.X509Certificate);
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "key-x509", CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Type.Should().Be(IssuerKeyType.X509Certificate);
}
[Fact]
public async Task UpsertAsync_PersistsKeyTypeDsse()
{
var keyRecord = CreateKeyRecord("key-dsse", IssuerKeyType.DssePublicKey);
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "key-dsse", CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Type.Should().Be(IssuerKeyType.DssePublicKey);
}
[Fact]
public async Task UpsertAsync_PersistsRevokedStatus()
{
var keyRecord = CreateKeyRecord("key-revoked", IssuerKeyType.Ed25519PublicKey) with
{
Status = IssuerKeyStatus.Revoked,
RevokedAtUtc = DateTimeOffset.UtcNow
};
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "key-revoked", CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Status.Should().Be(IssuerKeyStatus.Revoked);
fetched.RevokedAtUtc.Should().NotBeNull();
}
[Fact]
public async Task UpsertAsync_PersistsReplacesKeyId()
{
var oldKey = CreateKeyRecord("old-key", IssuerKeyType.Ed25519PublicKey) with
{
Status = IssuerKeyStatus.Retired,
RetiredAtUtc = DateTimeOffset.UtcNow
};
await _keyRepository.UpsertAsync(oldKey, CancellationToken.None);
var newKey = CreateKeyRecord("new-key", IssuerKeyType.Ed25519PublicKey) with
{
ReplacesKeyId = "old-key"
};
await _keyRepository.UpsertAsync(newKey, CancellationToken.None);
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "new-key", CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.ReplacesKeyId.Should().Be("old-key");
}
[Fact]
public async Task UpsertAsync_PersistsExpirationDate()
{
var expiresAt = DateTimeOffset.UtcNow.AddYears(1);
var keyRecord = CreateKeyRecord("key-expires", IssuerKeyType.Ed25519PublicKey) with
{
ExpiresAtUtc = expiresAt
};
await _keyRepository.UpsertAsync(keyRecord, CancellationToken.None);
var fetched = await _keyRepository.GetAsync(_tenantId, _issuerId, "key-expires", CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.ExpiresAtUtc.Should().BeCloseTo(expiresAt, TimeSpan.FromSeconds(1));
}
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 key 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 async Task<string> SeedGlobalIssuerAsync()
{
var issuerId = Guid.NewGuid().ToString();
var now = DateTimeOffset.UtcNow;
var issuer = new IssuerRecord
{
Id = issuerId,
TenantId = IssuerTenants.Global,
Slug = $"global-issuer-{Guid.NewGuid():N}",
DisplayName = "Global Test Issuer",
Description = "Global test issuer",
Contact = new IssuerContact(null, null, null, null),
Metadata = new IssuerMetadata(null, null, null, null, [], new Dictionary<string, string>()),
Endpoints = [],
Tags = [],
IsSystemSeed = true,
CreatedAtUtc = now,
CreatedBy = "system",
UpdatedAtUtc = now,
UpdatedBy = "system"
};
await _issuerRepository.UpsertAsync(issuer, CancellationToken.None);
return issuerId;
}
private IssuerKeyRecord CreateKeyRecord(string keyId, IssuerKeyType type, string? issuerId = null, string? tenantId = null)
{
var now = DateTimeOffset.UtcNow;
return new IssuerKeyRecord
{
Id = keyId,
IssuerId = issuerId ?? _issuerId,
TenantId = tenantId ?? _tenantId,
Type = type,
Status = IssuerKeyStatus.Active,
Material = new IssuerKeyMaterial("pem", $"-----BEGIN PUBLIC KEY-----\nMFkwE...\n-----END PUBLIC KEY-----"),
Fingerprint = $"fp_{Guid.NewGuid():N}",
CreatedAtUtc = now,
CreatedBy = "test@test.com",
UpdatedAtUtc = now,
UpdatedBy = "test@test.com",
ExpiresAtUtc = null,
RetiredAtUtc = null,
RevokedAtUtc = null,
ReplacesKeyId = null
};
}
}

View File

@@ -0,0 +1,220 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
[Collection(IssuerDirectoryPostgresCollection.Name)]
public sealed class IssuerRepositoryTests : IAsyncLifetime
{
private readonly IssuerDirectoryPostgresFixture _fixture;
private readonly PostgresIssuerRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
public IssuerRepositoryTests(IssuerDirectoryPostgresFixture fixture)
{
_fixture = fixture;
var options = new PostgresOptions
{
ConnectionString = fixture.ConnectionString,
SchemaName = fixture.SchemaName
};
var dataSource = new IssuerDirectoryDataSource(options, NullLogger<IssuerDirectoryDataSource>.Instance);
_repository = new PostgresIssuerRepository(dataSource, NullLogger<PostgresIssuerRepository>.Instance);
}
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task UpsertAsync_CreatesNewIssuer()
{
var record = CreateIssuerRecord("test-issuer", "Test Issuer");
await _repository.UpsertAsync(record, CancellationToken.None);
var fetched = await _repository.GetAsync(_tenantId, record.Id, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Id.Should().Be(record.Id);
fetched.Slug.Should().Be("test-issuer");
fetched.DisplayName.Should().Be("Test Issuer");
fetched.TenantId.Should().Be(_tenantId);
}
[Fact]
public async Task UpsertAsync_UpdatesExistingIssuer()
{
var record = CreateIssuerRecord("update-test", "Original Name");
await _repository.UpsertAsync(record, CancellationToken.None);
var updated = record with
{
DisplayName = "Updated Name",
Description = "Updated description",
UpdatedAtUtc = DateTimeOffset.UtcNow,
UpdatedBy = "updater@test.com"
};
await _repository.UpsertAsync(updated, CancellationToken.None);
var fetched = await _repository.GetAsync(_tenantId, record.Id, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.DisplayName.Should().Be("Updated Name");
fetched.Description.Should().Be("Updated description");
}
[Fact]
public async Task GetAsync_ReturnsNullForNonExistentIssuer()
{
var result = await _repository.GetAsync(_tenantId, Guid.NewGuid().ToString(), CancellationToken.None);
result.Should().BeNull();
}
[Fact]
public async Task ListAsync_ReturnsAllIssuersForTenant()
{
var issuer1 = CreateIssuerRecord("issuer-a", "Issuer A");
var issuer2 = CreateIssuerRecord("issuer-b", "Issuer B");
await _repository.UpsertAsync(issuer1, CancellationToken.None);
await _repository.UpsertAsync(issuer2, CancellationToken.None);
var results = await _repository.ListAsync(_tenantId, CancellationToken.None);
results.Should().HaveCount(2);
results.Select(i => i.Slug).Should().BeEquivalentTo(["issuer-a", "issuer-b"]);
}
[Fact]
public async Task ListGlobalAsync_ReturnsGlobalIssuers()
{
var globalIssuer = CreateIssuerRecord("global-issuer", "Global Issuer", IssuerTenants.Global);
await _repository.UpsertAsync(globalIssuer, CancellationToken.None);
var results = await _repository.ListGlobalAsync(CancellationToken.None);
results.Should().Contain(i => i.Slug == "global-issuer");
}
[Fact]
public async Task DeleteAsync_RemovesIssuer()
{
var record = CreateIssuerRecord("to-delete", "To Delete");
await _repository.UpsertAsync(record, CancellationToken.None);
await _repository.DeleteAsync(_tenantId, record.Id, CancellationToken.None);
var fetched = await _repository.GetAsync(_tenantId, record.Id, CancellationToken.None);
fetched.Should().BeNull();
}
[Fact]
public async Task UpsertAsync_PersistsContactInformation()
{
var contact = new IssuerContact(
"security@example.com",
"+1-555-0100",
new Uri("https://example.com/security"),
"UTC");
var record = CreateIssuerRecord("contact-test", "Contact Test") with { Contact = contact };
await _repository.UpsertAsync(record, CancellationToken.None);
var fetched = await _repository.GetAsync(_tenantId, record.Id, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Contact.Email.Should().Be("security@example.com");
fetched.Contact.Phone.Should().Be("+1-555-0100");
fetched.Contact.Website.Should().Be(new Uri("https://example.com/security"));
fetched.Contact.Timezone.Should().Be("UTC");
}
[Fact]
public async Task UpsertAsync_PersistsEndpoints()
{
var endpoints = new List<IssuerEndpoint>
{
new("csaf", new Uri("https://example.com/.well-known/csaf/provider-metadata.json"), "json", false),
new("oidc", new Uri("https://example.com/.well-known/openid-configuration"), "json", true)
};
var record = CreateIssuerRecord("endpoints-test", "Endpoints Test") with { Endpoints = endpoints };
await _repository.UpsertAsync(record, CancellationToken.None);
var fetched = await _repository.GetAsync(_tenantId, record.Id, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Endpoints.Should().HaveCount(2);
fetched.Endpoints.Should().Contain(e => e.Kind == "csaf");
fetched.Endpoints.Should().Contain(e => e.Kind == "oidc" && e.RequiresAuthentication);
}
[Fact]
public async Task UpsertAsync_PersistsMetadata()
{
var metadata = new IssuerMetadata(
"CVE-2024-0001",
"csaf-pub-123",
new Uri("https://example.com/security-advisories"),
new Uri("https://example.com/catalog"),
["en", "de"],
new Dictionary<string, string> { ["custom"] = "value" });
var record = CreateIssuerRecord("metadata-test", "Metadata Test") with { Metadata = metadata };
await _repository.UpsertAsync(record, CancellationToken.None);
var fetched = await _repository.GetAsync(_tenantId, record.Id, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Metadata.CveOrgId.Should().Be("CVE-2024-0001");
fetched.Metadata.CsafPublisherId.Should().Be("csaf-pub-123");
fetched.Metadata.SupportedLanguages.Should().BeEquivalentTo(["en", "de"]);
fetched.Metadata.Attributes.Should().ContainKey("custom");
}
[Fact]
public async Task UpsertAsync_PersistsTags()
{
var record = CreateIssuerRecord("tags-test", "Tags Test") with
{
Tags = ["vendor", "upstream", "critical"]
};
await _repository.UpsertAsync(record, CancellationToken.None);
var fetched = await _repository.GetAsync(_tenantId, record.Id, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Tags.Should().BeEquivalentTo(["vendor", "upstream", "critical"]);
}
private IssuerRecord CreateIssuerRecord(string slug, string displayName, string? tenantId = null)
{
var now = DateTimeOffset.UtcNow;
return new IssuerRecord
{
Id = Guid.NewGuid().ToString(),
TenantId = tenantId ?? _tenantId,
Slug = slug,
DisplayName = displayName,
Description = $"Test issuer: {displayName}",
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"
};
}
}

View File

@@ -0,0 +1,190 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
[Collection(IssuerDirectoryPostgresCollection.Name)]
public sealed class IssuerTrustRepositoryTests : IAsyncLifetime
{
private readonly IssuerDirectoryPostgresFixture _fixture;
private readonly PostgresIssuerRepository _issuerRepository;
private readonly PostgresIssuerTrustRepository _trustRepository;
private readonly string _tenantId = Guid.NewGuid().ToString();
private string _issuerId = null!;
public IssuerTrustRepositoryTests(IssuerDirectoryPostgresFixture fixture)
{
_fixture = fixture;
var options = new PostgresOptions
{
ConnectionString = fixture.ConnectionString,
SchemaName = fixture.SchemaName
};
var dataSource = new IssuerDirectoryDataSource(options, NullLogger<IssuerDirectoryDataSource>.Instance);
_issuerRepository = new PostgresIssuerRepository(dataSource, NullLogger<PostgresIssuerRepository>.Instance);
_trustRepository = new PostgresIssuerTrustRepository(dataSource, NullLogger<PostgresIssuerTrustRepository>.Instance);
}
public async Task InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
_issuerId = await SeedIssuerAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task UpsertAsync_CreatesNewTrustOverride()
{
var record = CreateTrustRecord(5.5m, "Trusted vendor");
await _trustRepository.UpsertAsync(record, CancellationToken.None);
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Weight.Should().Be(5.5m);
fetched.Reason.Should().Be("Trusted vendor");
}
[Fact]
public async Task UpsertAsync_UpdatesExistingTrustOverride()
{
var record = CreateTrustRecord(3.0m, "Initial trust");
await _trustRepository.UpsertAsync(record, CancellationToken.None);
var updated = record.WithUpdated(7.5m, "Upgraded trust", DateTimeOffset.UtcNow, "admin@test.com");
await _trustRepository.UpsertAsync(updated, CancellationToken.None);
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Weight.Should().Be(7.5m);
fetched.Reason.Should().Be("Upgraded trust");
fetched.UpdatedBy.Should().Be("admin@test.com");
}
[Fact]
public async Task GetAsync_ReturnsNullForNonExistentOverride()
{
var result = await _trustRepository.GetAsync(_tenantId, Guid.NewGuid().ToString(), CancellationToken.None);
result.Should().BeNull();
}
[Fact]
public async Task DeleteAsync_RemovesTrustOverride()
{
var record = CreateTrustRecord(2.0m, "To be deleted");
await _trustRepository.UpsertAsync(record, CancellationToken.None);
await _trustRepository.DeleteAsync(_tenantId, _issuerId, CancellationToken.None);
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
fetched.Should().BeNull();
}
[Fact]
public async Task UpsertAsync_PersistsPositiveWeight()
{
var record = CreateTrustRecord(10.0m, "Maximum trust");
await _trustRepository.UpsertAsync(record, CancellationToken.None);
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Weight.Should().Be(10.0m);
}
[Fact]
public async Task UpsertAsync_PersistsNegativeWeight()
{
var record = CreateTrustRecord(-5.0m, "Distrust override");
await _trustRepository.UpsertAsync(record, CancellationToken.None);
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Weight.Should().Be(-5.0m);
}
[Fact]
public async Task UpsertAsync_PersistsZeroWeight()
{
var record = CreateTrustRecord(0m, "Neutral trust");
await _trustRepository.UpsertAsync(record, CancellationToken.None);
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Weight.Should().Be(0m);
}
[Fact]
public async Task UpsertAsync_PersistsNullReason()
{
var record = CreateTrustRecord(5.0m, null);
await _trustRepository.UpsertAsync(record, CancellationToken.None);
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Reason.Should().BeNull();
}
[Fact]
public async Task UpsertAsync_PersistsTimestamps()
{
var now = DateTimeOffset.UtcNow;
var record = CreateTrustRecord(5.0m, "Time test");
await _trustRepository.UpsertAsync(record, CancellationToken.None);
var fetched = await _trustRepository.GetAsync(_tenantId, _issuerId, CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.CreatedAtUtc.Should().BeCloseTo(now, TimeSpan.FromSeconds(5));
fetched.UpdatedAtUtc.Should().BeCloseTo(now, TimeSpan.FromSeconds(5));
fetched.CreatedBy.Should().Be("test@test.com");
fetched.UpdatedBy.Should().Be("test@test.com");
}
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 trust 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 IssuerTrustOverrideRecord CreateTrustRecord(decimal weight, string? reason)
{
return IssuerTrustOverrideRecord.Create(
_issuerId,
_tenantId,
weight,
reason,
DateTimeOffset.UtcNow,
"test@test.com");
}
}

View File

@@ -0,0 +1,37 @@
<?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>
<!-- Disable Concelier test infra since we use our own Postgres testing infra -->
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<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>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.IssuerDirectory.Storage.Postgres\StellaOps.IssuerDirectory.Storage.Postgres.csproj" />
<ProjectReference Include="..\..\StellaOps.IssuerDirectory.Core\StellaOps.IssuerDirectory.Core.csproj" />
<ProjectReference Include="..\..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
</ItemGroup>
</Project>

View File

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

View File

@@ -0,0 +1,75 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Storage.Postgres;
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
public class IssuerKeyRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixture>
{
private readonly IssuerDirectoryPostgresFixture _fixture;
public IssuerKeyRepositoryTests(IssuerDirectoryPostgresFixture fixture)
{
_fixture = fixture;
}
private PostgresIssuerRepository CreateIssuerRepo() =>
new(new IssuerDirectoryDataSource(_fixture.Fixture.Options, NullLogger<IssuerDirectoryDataSource>.Instance),
NullLogger<PostgresIssuerRepository>.Instance);
private PostgresIssuerKeyRepository CreateKeyRepo() =>
new(new IssuerDirectoryDataSource(_fixture.Fixture.Options, NullLogger<IssuerDirectoryDataSource>.Instance),
NullLogger<PostgresIssuerKeyRepository>.Instance);
[Fact]
public async Task AddKey_And_List_Works()
{
var tenant = Guid.NewGuid().ToString();
var issuerId = Guid.NewGuid().ToString();
var issuerRepo = CreateIssuerRepo();
var keyRepo = CreateKeyRepo();
var issuer = new IssuerRecord(
issuerId,
tenant,
slug: "vendor-x",
displayName: "Vendor X",
description: null,
endpoints: Array.Empty<IssuerEndpoint>(),
contact: new IssuerContact(null, null),
metadata: new IssuerMetadata(Array.Empty<string>(), null),
tags: Array.Empty<string>(),
status: "active",
isSystemSeed: false,
createdAt: DateTimeOffset.UtcNow,
createdBy: "test",
updatedAt: DateTimeOffset.UtcNow,
updatedBy: "test");
await issuerRepo.UpsertAsync(issuer, CancellationToken.None);
var key = new IssuerKeyRecord(
id: Guid.NewGuid().ToString(),
issuerId: issuerId,
keyId: "kid-1",
keyType: IssuerKeyType.Ed25519,
publicKey: "pubkey",
fingerprint: "fp-1",
notBefore: null,
notAfter: null,
status: IssuerKeyStatus.Active,
createdAt: DateTimeOffset.UtcNow,
createdBy: "test",
revokedAt: null,
revokedBy: null,
revokeReason: null,
metadata: new IssuerKeyMetadata(null, null));
await keyRepo.UpsertAsync(key, CancellationToken.None);
var keys = await keyRepo.ListAsync(tenant, issuerId, CancellationToken.None);
keys.Should().ContainSingle(k => k.KeyId == "kid-1" && k.IssuerId == issuerId);
}
}

View File

@@ -0,0 +1,59 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Storage.Postgres;
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
public class IssuerRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixture>
{
private readonly IssuerDirectoryPostgresFixture _fixture;
public IssuerRepositoryTests(IssuerDirectoryPostgresFixture fixture)
{
_fixture = fixture;
}
private PostgresIssuerRepository CreateRepository()
{
var dataSource = new IssuerDirectoryDataSource(
_fixture.Fixture.Options,
NullLogger<IssuerDirectoryDataSource>.Instance);
return new PostgresIssuerRepository(dataSource, NullLogger<PostgresIssuerRepository>.Instance);
}
[Fact]
public async Task UpsertAndGet_Works_For_Tenant()
{
var repo = CreateRepository();
var tenant = Guid.NewGuid().ToString();
var issuerId = Guid.NewGuid().ToString();
var record = new IssuerRecord(
issuerId,
tenant,
slug: "acme",
displayName: "Acme Corp",
description: "Test issuer",
endpoints: new[] { new IssuerEndpoint("csaf", "https://acme.test/csaf") },
contact: new IssuerContact("security@acme.test", null),
metadata: new IssuerMetadata(Array.Empty<string>(), null),
tags: new[] { "vendor", "csaf" },
status: "active",
isSystemSeed: false,
createdAt: DateTimeOffset.UtcNow,
createdBy: "test",
updatedAt: DateTimeOffset.UtcNow,
updatedBy: "test");
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);
}
}

View File

@@ -0,0 +1,28 @@
<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>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<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>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\..\\StellaOps.IssuerDirectory\\StellaOps.IssuerDirectory.Storage.Postgres\\StellaOps.IssuerDirectory.Storage.Postgres.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres.Testing\\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="..\\..\\StellaOps.IssuerDirectory\\StellaOps.IssuerDirectory.Core\\StellaOps.IssuerDirectory.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,71 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.IssuerDirectory.Core.Domain;
using StellaOps.IssuerDirectory.Storage.Postgres;
using StellaOps.IssuerDirectory.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.IssuerDirectory.Storage.Postgres.Tests;
public class TrustRepositoryTests : IClassFixture<IssuerDirectoryPostgresFixture>
{
private readonly IssuerDirectoryPostgresFixture _fixture;
public TrustRepositoryTests(IssuerDirectoryPostgresFixture fixture)
{
_fixture = fixture;
}
private PostgresIssuerRepository CreateIssuerRepo() =>
new(new IssuerDirectoryDataSource(_fixture.Fixture.Options, NullLogger<IssuerDirectoryDataSource>.Instance),
NullLogger<PostgresIssuerRepository>.Instance);
private PostgresIssuerTrustRepository CreateTrustRepo() =>
new(new IssuerDirectoryDataSource(_fixture.Fixture.Options, NullLogger<IssuerDirectoryDataSource>.Instance),
NullLogger<PostgresIssuerTrustRepository>.Instance);
[Fact]
public async Task UpsertTrustOverride_Works()
{
var tenant = Guid.NewGuid().ToString();
var issuerId = Guid.NewGuid().ToString();
var issuerRepo = CreateIssuerRepo();
var trustRepo = CreateTrustRepo();
var issuer = new IssuerRecord(
issuerId,
tenant,
slug: "trusty",
displayName: "Trusty Issuer",
description: null,
endpoints: Array.Empty<IssuerEndpoint>(),
contact: new IssuerContact(null, null),
metadata: new IssuerMetadata(Array.Empty<string>(), null),
tags: Array.Empty<string>(),
status: "active",
isSystemSeed: false,
createdAt: DateTimeOffset.UtcNow,
createdBy: "test",
updatedAt: DateTimeOffset.UtcNow,
updatedBy: "test");
await issuerRepo.UpsertAsync(issuer, CancellationToken.None);
var trust = new IssuerTrustOverrideRecord(
id: Guid.NewGuid().ToString(),
issuerId: issuerId,
tenantId: tenant,
weight: 0.75m,
rationale: "vendor override",
expiresAt: null,
createdAt: DateTimeOffset.UtcNow,
createdBy: "test",
updatedAt: DateTimeOffset.UtcNow,
updatedBy: "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);
}
}