Add unit tests and logging infrastructure for InMemory and RabbitMQ transports
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user