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:
StellaOps Bot
2025-12-20 18:40:34 +02:00
parent 3698ebf4a8
commit 3c6e14fca5
8 changed files with 1111 additions and 71 deletions

View File

@@ -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;
}

View File

@@ -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>();

View File

@@ -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;
}
}