fix(scanner): Fix WebService test infrastructure failure
- Update PostgresIdempotencyKeyRepository to use ScannerDataSource instead of NpgsqlDataSource directly (aligns with other Postgres repositories) - Move IIdempotencyKeyRepository registration from IdempotencyMiddlewareExtensions to ServiceCollectionExtensions.RegisterScannerStorageServices - Use Dapper instead of raw NpgsqlCommand for consistency - Fixes: System.InvalidOperationException: Unable to resolve service for type 'Npgsql.NpgsqlDataSource' when running WebService tests Sprint planning: - Create SPRINT_3500_0004_0001 CLI Verbs & Offline Bundles - Create SPRINT_3500_0004_0002 UI Components & Visualization - Create SPRINT_3500_0004_0003 Integration Tests & Corpus - Create SPRINT_3500_0004_0004 Documentation & Handoff Sprint: SPRINT_3500_0002_0003
This commit is contained in:
@@ -8,8 +8,6 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Middleware;
|
||||
@@ -21,6 +19,7 @@ public static class IdempotencyMiddlewareExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds idempotency services to the service collection.
|
||||
/// Note: IIdempotencyKeyRepository is registered by AddScannerStorage.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddIdempotency(
|
||||
this IServiceCollection services,
|
||||
@@ -32,8 +31,6 @@ public static class IdempotencyMiddlewareExtensions
|
||||
services.Configure<IdempotencyOptions>(
|
||||
configuration.GetSection(IdempotencyOptions.SectionName));
|
||||
|
||||
services.AddScoped<IIdempotencyKeyRepository, PostgresIdempotencyKeyRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ICodeChangeRepository, PostgresCodeChangeRepository>();
|
||||
services.AddScoped<IReachabilityDriftResultRepository, PostgresReachabilityDriftResultRepository>();
|
||||
|
||||
// Idempotency key storage (Sprint: SPRINT_3500_0002_0003)
|
||||
services.AddScoped<IIdempotencyKeyRepository, PostgresIdempotencyKeyRepository>();
|
||||
|
||||
// EPSS ingestion services
|
||||
services.AddSingleton<EpssCsvStreamParser>();
|
||||
services.AddScoped<IEpssRepository, PostgresEpssRepository>();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Description: PostgreSQL implementation of idempotency key repository
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
@@ -17,11 +18,12 @@ namespace StellaOps.Scanner.Storage.Postgres;
|
||||
/// </summary>
|
||||
public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresIdempotencyKeyRepository> _logger;
|
||||
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
|
||||
public PostgresIdempotencyKeyRepository(
|
||||
NpgsqlDataSource dataSource,
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<PostgresIdempotencyKeyRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
@@ -35,41 +37,28 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
|
||||
string endpointPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT key_id, tenant_id, content_digest, endpoint_path,
|
||||
response_status, response_body, response_headers,
|
||||
created_at, expires_at
|
||||
FROM scanner.idempotency_keys
|
||||
WHERE tenant_id = @tenantId
|
||||
AND content_digest = @contentDigest
|
||||
AND endpoint_path = @endpointPath
|
||||
var sql = $"""
|
||||
SELECT
|
||||
key_id AS KeyId,
|
||||
tenant_id AS TenantId,
|
||||
content_digest AS ContentDigest,
|
||||
endpoint_path AS EndpointPath,
|
||||
response_status AS ResponseStatus,
|
||||
response_body AS ResponseBody,
|
||||
response_headers AS ResponseHeaders,
|
||||
created_at AS CreatedAt,
|
||||
expires_at AS ExpiresAt
|
||||
FROM {SchemaName}.idempotency_keys
|
||||
WHERE tenant_id = @TenantId
|
||||
AND content_digest = @ContentDigest
|
||||
AND endpoint_path = @EndpointPath
|
||||
AND expires_at > now()
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("contentDigest", contentDigest);
|
||||
cmd.Parameters.AddWithValue("endpointPath", endpointPath);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new IdempotencyKeyRow
|
||||
{
|
||||
KeyId = reader.GetGuid(0),
|
||||
TenantId = reader.GetString(1),
|
||||
ContentDigest = reader.GetString(2),
|
||||
EndpointPath = reader.GetString(3),
|
||||
ResponseStatus = reader.GetInt32(4),
|
||||
ResponseBody = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
ResponseHeaders = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
CreatedAt = reader.GetDateTime(7),
|
||||
ExpiresAt = reader.GetDateTime(8)
|
||||
};
|
||||
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await conn.QuerySingleOrDefaultAsync<IdempotencyKeyRow>(
|
||||
new CommandDefinition(sql, new { TenantId = tenantId, ContentDigest = contentDigest, EndpointPath = endpointPath }, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -77,15 +66,20 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
|
||||
IdempotencyKeyRow key,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO scanner.idempotency_keys
|
||||
if (key.KeyId == Guid.Empty)
|
||||
{
|
||||
key.KeyId = Guid.NewGuid();
|
||||
}
|
||||
|
||||
var sql = $"""
|
||||
INSERT INTO {SchemaName}.idempotency_keys
|
||||
(key_id, tenant_id, content_digest, endpoint_path,
|
||||
response_status, response_body, response_headers,
|
||||
created_at, expires_at)
|
||||
VALUES
|
||||
(@keyId, @tenantId, @contentDigest, @endpointPath,
|
||||
@responseStatus, @responseBody::jsonb, @responseHeaders::jsonb,
|
||||
@createdAt, @expiresAt)
|
||||
(@KeyId, @TenantId, @ContentDigest, @EndpointPath,
|
||||
@ResponseStatus, @ResponseBody::jsonb, @ResponseHeaders::jsonb,
|
||||
@CreatedAt, @ExpiresAt)
|
||||
ON CONFLICT (tenant_id, content_digest, endpoint_path) DO UPDATE
|
||||
SET response_status = EXCLUDED.response_status,
|
||||
response_body = EXCLUDED.response_body,
|
||||
@@ -95,26 +89,23 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
|
||||
RETURNING key_id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var keyId = await conn.ExecuteScalarAsync<Guid>(
|
||||
new CommandDefinition(sql, new
|
||||
{
|
||||
key.KeyId,
|
||||
key.TenantId,
|
||||
key.ContentDigest,
|
||||
key.EndpointPath,
|
||||
key.ResponseStatus,
|
||||
key.ResponseBody,
|
||||
key.ResponseHeaders,
|
||||
key.CreatedAt,
|
||||
key.ExpiresAt
|
||||
}, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (key.KeyId == Guid.Empty)
|
||||
{
|
||||
key.KeyId = Guid.NewGuid();
|
||||
}
|
||||
|
||||
cmd.Parameters.AddWithValue("keyId", key.KeyId);
|
||||
cmd.Parameters.AddWithValue("tenantId", key.TenantId);
|
||||
cmd.Parameters.AddWithValue("contentDigest", key.ContentDigest);
|
||||
cmd.Parameters.AddWithValue("endpointPath", key.EndpointPath);
|
||||
cmd.Parameters.AddWithValue("responseStatus", key.ResponseStatus);
|
||||
cmd.Parameters.AddWithValue("responseBody", (object?)key.ResponseBody ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("responseHeaders", (object?)key.ResponseHeaders ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("createdAt", key.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("expiresAt", key.ExpiresAt);
|
||||
|
||||
var keyId = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
key.KeyId = (Guid)keyId!;
|
||||
key.KeyId = keyId;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Saved idempotency key {KeyId} for tenant {TenantId}, digest {ContentDigest}",
|
||||
@@ -126,19 +117,18 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
|
||||
/// <inheritdoc />
|
||||
public async Task<int> DeleteExpiredAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT scanner.cleanup_expired_idempotency_keys()";
|
||||
var sql = $"SELECT {SchemaName}.cleanup_expired_idempotency_keys()";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
await using var conn = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var result = await conn.ExecuteScalarAsync<int>(
|
||||
new CommandDefinition(sql, cancellationToken: cancellationToken))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
var deletedCount = Convert.ToInt32(result);
|
||||
|
||||
if (deletedCount > 0)
|
||||
if (result > 0)
|
||||
{
|
||||
_logger.LogInformation("Cleaned up {Count} expired idempotency keys", deletedCount);
|
||||
_logger.LogInformation("Cleaned up {Count} expired idempotency keys", result);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user