consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

28
src/Remediation/AGENTS.md Normal file
View File

@@ -0,0 +1,28 @@
# Remediation Module Agent Charter
## Mission
- Deliver deterministic remediation APIs and persistence behavior for templates, submissions, and marketplace sources.
- Keep runtime storage contracts explicit and fail-fast for unsupported deployment profiles.
## Working Directory
- `src/Remediation/`
## Module Layout
- `StellaOps.Remediation.Core/` - domain contracts, models, and core services.
- `StellaOps.Remediation.Persistence/` - PostgreSQL data source, repositories, and EF persistence model.
- `StellaOps.Remediation.WebService/` - HTTP host and endpoint wiring.
- `__Tests/StellaOps.Remediation.Tests/` - repository/domain tests.
- `__Tests/StellaOps.Remediation.WebService.Tests/` - endpoint and startup contract tests.
## Required Reading
- `docs/README.md`
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
- `docs/modules/platform/architecture.md`
- `docs/modules/remediation/architecture.md`
- `docs/implplan/SPRINT_20260305_004_Remediation_postgres_runtime_wiring.md`
## Working Agreement
- Update sprint task state (`TODO`, `DOING`, `DONE`, `BLOCKED`) in `docs/implplan/SPRINT_*.md` as work progresses.
- Keep storage mode behavior deterministic with explicit startup validation for unsupported profiles.
- Add or update targeted tests whenever runtime wiring, contracts, or endpoint behavior changes.
- Update module docs and sprint `Decisions & Risks` when implementation behavior changes.

View File

@@ -0,0 +1,20 @@
using StellaOps.Remediation.Core.Models;
namespace StellaOps.Remediation.Persistence.Repositories;
public interface IMarketplaceSourceRepository
{
Task<IReadOnlyList<MarketplaceSource>> ListAsync(
string tenantId,
CancellationToken ct = default);
Task<MarketplaceSource?> GetByKeyAsync(
string tenantId,
string key,
CancellationToken ct = default);
Task<MarketplaceSource> UpsertAsync(
string tenantId,
MarketplaceSource source,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,270 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Remediation.Core.Models;
using StellaOps.Remediation.Persistence.EfCore.Models;
using StellaOps.Remediation.Persistence.Postgres;
namespace StellaOps.Remediation.Persistence.Repositories;
/// <summary>
/// EF Core-backed PostgreSQL implementation of <see cref="IMarketplaceSourceRepository"/>.
/// Operates against remediation.marketplace_sources with tenant-scoped key composition.
/// When constructed without a data source, operates in deterministic in-memory mode.
/// </summary>
public sealed class PostgresMarketplaceSourceRepository : IMarketplaceSourceRepository
{
private const int CommandTimeoutSeconds = 30;
private const string TenantKeyDelimiter = "::";
private const string DefaultTenant = "default";
private readonly RemediationDataSource? _dataSource;
private readonly Dictionary<string, MarketplaceSource>? _inMemoryStore;
private readonly object _inMemoryLock = new();
public PostgresMarketplaceSourceRepository(RemediationDataSource dataSource)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
public PostgresMarketplaceSourceRepository()
{
_inMemoryStore = new Dictionary<string, MarketplaceSource>(StringComparer.Ordinal);
}
public async Task<IReadOnlyList<MarketplaceSource>> ListAsync(
string tenantId,
CancellationToken ct = default)
{
var normalizedTenantId = NormalizeTenantId(tenantId);
var tenantPrefix = $"{normalizedTenantId}{TenantKeyDelimiter}";
if (_dataSource is null)
{
lock (_inMemoryLock)
{
return _inMemoryStore!
.Where(entry => entry.Key.StartsWith(tenantPrefix, StringComparison.Ordinal))
.Select(static entry => entry.Value)
.OrderBy(static source => source.Key, StringComparer.Ordinal)
.ThenBy(static source => source.CreatedAt)
.ThenBy(static source => source.Id)
.ToList();
}
}
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var entities = await dbContext.MarketplaceSources
.AsNoTracking()
.Where(entity => EF.Functions.Like(entity.Key, $"{tenantPrefix}%"))
.ToListAsync(ct)
.ConfigureAwait(false);
return entities
.Select(entity => ToModel(entity, normalizedTenantId))
.OrderBy(static source => source.Key, StringComparer.Ordinal)
.ThenBy(static source => source.CreatedAt)
.ThenBy(static source => source.Id)
.ToList();
}
public async Task<MarketplaceSource?> GetByKeyAsync(
string tenantId,
string key,
CancellationToken ct = default)
{
var normalizedTenantId = NormalizeTenantId(tenantId);
var normalizedKey = NormalizeSourceKey(key);
var tenantScopedKey = BuildTenantScopedKey(normalizedTenantId, normalizedKey);
if (_dataSource is null)
{
lock (_inMemoryLock)
{
return _inMemoryStore!.TryGetValue(tenantScopedKey, out var source)
? source
: null;
}
}
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var entity = await dbContext.MarketplaceSources
.AsNoTracking()
.FirstOrDefaultAsync(source => source.Key == tenantScopedKey, ct)
.ConfigureAwait(false);
return entity is null ? null : ToModel(entity, normalizedTenantId);
}
public async Task<MarketplaceSource> UpsertAsync(
string tenantId,
MarketplaceSource source,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(source);
var normalizedTenantId = NormalizeTenantId(tenantId);
var normalizedKey = NormalizeSourceKey(source.Key);
var tenantScopedKey = BuildTenantScopedKey(normalizedTenantId, normalizedKey);
if (_dataSource is null)
{
lock (_inMemoryLock)
{
if (_inMemoryStore!.TryGetValue(tenantScopedKey, out var existing))
{
var updated = existing with
{
Name = NormalizeName(source.Name),
Url = NormalizeUrl(source.Url),
SourceType = NormalizeSourceType(source.SourceType),
Enabled = source.Enabled,
TrustScore = source.TrustScore,
LastSyncAt = source.LastSyncAt
};
_inMemoryStore[tenantScopedKey] = updated;
return updated;
}
var created = source with
{
Id = source.Id == Guid.Empty ? Guid.NewGuid() : source.Id,
Key = normalizedKey,
Name = NormalizeName(source.Name),
Url = NormalizeUrl(source.Url),
SourceType = NormalizeSourceType(source.SourceType),
CreatedAt = source.CreatedAt == default ? DateTimeOffset.UtcNow : source.CreatedAt
};
_inMemoryStore[tenantScopedKey] = created;
return created;
}
}
await using var connection = await _dataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
await using var dbContext = RemediationDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var existingEntity = await dbContext.MarketplaceSources
.FirstOrDefaultAsync(entity => entity.Key == tenantScopedKey, ct)
.ConfigureAwait(false);
if (existingEntity is null)
{
var entity = new MarketplaceSourceEntity
{
Id = source.Id == Guid.Empty ? Guid.NewGuid() : source.Id,
Key = tenantScopedKey,
Name = NormalizeName(source.Name),
Url = NormalizeUrl(source.Url),
SourceType = NormalizeSourceType(source.SourceType),
Enabled = source.Enabled,
TrustScore = source.TrustScore,
CreatedAt = source.CreatedAt == default ? DateTime.UtcNow : source.CreatedAt.UtcDateTime,
LastSyncAt = source.LastSyncAt?.UtcDateTime
};
dbContext.MarketplaceSources.Add(entity);
try
{
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
return ToModel(entity, normalizedTenantId);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
existingEntity = await dbContext.MarketplaceSources
.FirstOrDefaultAsync(item => item.Key == tenantScopedKey, ct)
.ConfigureAwait(false);
if (existingEntity is null)
{
throw;
}
}
}
existingEntity.Name = NormalizeName(source.Name);
existingEntity.Url = NormalizeUrl(source.Url);
existingEntity.SourceType = NormalizeSourceType(source.SourceType);
existingEntity.Enabled = source.Enabled;
existingEntity.TrustScore = source.TrustScore;
existingEntity.LastSyncAt = source.LastSyncAt?.UtcDateTime;
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
return ToModel(existingEntity, normalizedTenantId);
}
private static string NormalizeTenantId(string tenantId)
{
var normalized = tenantId?.Trim().ToLowerInvariant();
return string.IsNullOrWhiteSpace(normalized) ? DefaultTenant : normalized;
}
private static string NormalizeSourceKey(string key)
{
return key.Trim().ToLowerInvariant();
}
private static string NormalizeName(string name)
{
return name.Trim();
}
private static string NormalizeSourceType(string sourceType)
{
return sourceType.Trim().ToLowerInvariant();
}
private static string? NormalizeUrl(string? url)
{
return string.IsNullOrWhiteSpace(url) ? null : url.Trim();
}
private static string BuildTenantScopedKey(string normalizedTenantId, string normalizedSourceKey)
{
return $"{normalizedTenantId}{TenantKeyDelimiter}{normalizedSourceKey}";
}
private static MarketplaceSource ToModel(MarketplaceSourceEntity entity, string normalizedTenantId)
{
var expectedPrefix = $"{normalizedTenantId}{TenantKeyDelimiter}";
var sourceKey = entity.Key.StartsWith(expectedPrefix, StringComparison.Ordinal)
? entity.Key[expectedPrefix.Length..]
: entity.Key;
return new MarketplaceSource
{
Id = entity.Id,
Key = sourceKey,
Name = entity.Name,
Url = entity.Url,
SourceType = entity.SourceType,
Enabled = entity.Enabled,
TrustScore = entity.TrustScore,
CreatedAt = new DateTimeOffset(entity.CreatedAt, TimeSpan.Zero),
LastSyncAt = entity.LastSyncAt.HasValue
? new DateTimeOffset(entity.LastSyncAt.Value, TimeSpan.Zero)
: null
};
}
private static bool IsUniqueViolation(DbUpdateException exception)
{
Exception? current = exception;
while (current is not null)
{
if (current is Npgsql.PostgresException { SqlState: "23505" })
{
return true;
}
current = current.InnerException;
}
return false;
}
private string GetSchemaName() => RemediationDataSource.DefaultSchemaName;
}

View File

@@ -57,3 +57,26 @@ public sealed record ContributorResponse(
public sealed record MatchResponse(
IReadOnlyList<FixTemplateSummary> Items,
int Count);
public sealed record MarketplaceSourceListResponse(
IReadOnlyList<MarketplaceSourceSummary> Items,
int Count);
public sealed record MarketplaceSourceSummary(
string Key,
string Name,
string? Url,
string SourceType,
bool Enabled,
double TrustScore,
DateTimeOffset CreatedAt,
DateTimeOffset? LastSyncAt);
public sealed record UpsertMarketplaceSourceRequest(
string Key,
string Name,
string? Url,
string SourceType,
bool Enabled,
double TrustScore,
DateTimeOffset? LastSyncAt);

View File

@@ -1,45 +1,208 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Remediation.Core.Models;
using StellaOps.Remediation.Persistence.Repositories;
using StellaOps.Remediation.WebService.Contracts;
using System.Text.RegularExpressions;
namespace StellaOps.Remediation.WebService.Endpoints;
public static class RemediationSourceEndpoints
{
private static readonly Regex SourceKeyPattern = new(
"^[a-z0-9][a-z0-9._-]{0,63}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly HashSet<string> AllowedSourceTypes = new(StringComparer.Ordinal)
{
"community",
"partner",
"vendor"
};
public static IEndpointRouteBuilder MapRemediationSourceEndpoints(this IEndpointRouteBuilder app)
{
var sources = app.MapGroup("/api/v1/remediation/sources")
.WithTags("Remediation")
.RequireTenant();
sources.MapGet(string.Empty, () =>
sources.MapGet(string.Empty, async Task<IResult>(
IMarketplaceSourceRepository repository,
IStellaOpsTenantAccessor tenantAccessor,
CancellationToken ct) =>
{
// Stub: list marketplace sources
return Results.Ok(new { items = Array.Empty<object>(), count = 0 });
var tenantId = GetTenantOrDefault(tenantAccessor);
var items = await repository.ListAsync(tenantId, ct).ConfigureAwait(false);
var summaries = items
.OrderBy(static source => source.Key, StringComparer.Ordinal)
.Select(MapSummary)
.ToList();
return Results.Ok(new MarketplaceSourceListResponse(summaries, summaries.Count));
})
.WithName("ListMarketplaceSources")
.WithSummary("List remediation marketplace sources")
.RequireAuthorization("remediation.read");
sources.MapGet("/{key}", (string key) =>
sources.MapGet("/{key}", async Task<IResult>(
string key,
IMarketplaceSourceRepository repository,
IStellaOpsTenantAccessor tenantAccessor,
CancellationToken ct) =>
{
// Stub: get marketplace source by key
return Results.NotFound(new { error = "source_not_found", key });
var errors = ValidateKey(key);
if (errors.Count > 0)
{
return Results.ValidationProblem(errors);
}
var normalizedKey = NormalizeKey(key);
var tenantId = GetTenantOrDefault(tenantAccessor);
var source = await repository.GetByKeyAsync(tenantId, normalizedKey, ct).ConfigureAwait(false);
if (source is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Marketplace source not found",
Detail = $"Source '{normalizedKey}' was not found for tenant '{tenantId}'.",
Status = StatusCodes.Status404NotFound,
Type = "https://stellaops.dev/problems/remediation/source-not-found"
});
}
return Results.Ok(MapSummary(source));
})
.WithName("GetMarketplaceSource")
.WithSummary("Get marketplace source by key")
.RequireAuthorization("remediation.read");
sources.MapPost(string.Empty, () =>
sources.MapPost(string.Empty, async Task<IResult>(
UpsertMarketplaceSourceRequest request,
IMarketplaceSourceRepository repository,
IStellaOpsTenantAccessor tenantAccessor,
CancellationToken ct) =>
{
// Stub: create or update marketplace source
return Results.StatusCode(StatusCodes.Status501NotImplemented);
var validationErrors = ValidateUpsertRequest(request);
if (validationErrors.Count > 0)
{
return Results.ValidationProblem(validationErrors);
}
var tenantId = GetTenantOrDefault(tenantAccessor);
var normalizedKey = NormalizeKey(request.Key);
var upserted = await repository.UpsertAsync(
tenantId,
new MarketplaceSource
{
Key = normalizedKey,
Name = request.Name.Trim(),
Url = string.IsNullOrWhiteSpace(request.Url) ? null : request.Url.Trim(),
SourceType = request.SourceType.Trim().ToLowerInvariant(),
Enabled = request.Enabled,
TrustScore = request.TrustScore,
LastSyncAt = request.LastSyncAt
},
ct).ConfigureAwait(false);
return Results.Ok(MapSummary(upserted));
})
.WithName("CreateMarketplaceSource")
.WithName("UpsertMarketplaceSource")
.WithSummary("Create or update marketplace source")
.RequireAuthorization("remediation.manage");
return app;
}
private static string GetTenantOrDefault(IStellaOpsTenantAccessor tenantAccessor)
{
var tenantId = tenantAccessor.TenantId;
return string.IsNullOrWhiteSpace(tenantId)
? "default"
: tenantId.Trim().ToLowerInvariant();
}
private static MarketplaceSourceSummary MapSummary(MarketplaceSource source)
{
return new MarketplaceSourceSummary(
Key: source.Key,
Name: source.Name,
Url: source.Url,
SourceType: source.SourceType,
Enabled: source.Enabled,
TrustScore: source.TrustScore,
CreatedAt: source.CreatedAt,
LastSyncAt: source.LastSyncAt);
}
private static Dictionary<string, string[]> ValidateUpsertRequest(UpsertMarketplaceSourceRequest request)
{
var errors = new Dictionary<string, string[]>(StringComparer.Ordinal);
MergeErrors(errors, ValidateKey(request.Key));
if (string.IsNullOrWhiteSpace(request.Name))
{
errors["name"] = ["Name is required."];
}
var sourceType = request.SourceType?.Trim().ToLowerInvariant() ?? string.Empty;
if (!AllowedSourceTypes.Contains(sourceType))
{
errors["sourceType"] = ["Source type must be one of: community, partner, vendor."];
}
if (request.TrustScore is < 0 or > 1)
{
errors["trustScore"] = ["Trust score must be between 0 and 1."];
}
if (!string.IsNullOrWhiteSpace(request.Url))
{
var url = request.Url.Trim();
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
errors["url"] = ["URL must be an absolute http/https URL when provided."];
}
}
return errors;
}
private static Dictionary<string, string[]> ValidateKey(string key)
{
var errors = new Dictionary<string, string[]>(StringComparer.Ordinal);
if (string.IsNullOrWhiteSpace(key))
{
errors["key"] = ["Key is required."];
return errors;
}
var normalizedKey = NormalizeKey(key);
if (!SourceKeyPattern.IsMatch(normalizedKey))
{
errors["key"] = ["Key must match pattern ^[a-z0-9][a-z0-9._-]{0,63}$."];
}
return errors;
}
private static string NormalizeKey(string key)
{
return key.Trim().ToLowerInvariant();
}
private static void MergeErrors(
IDictionary<string, string[]> target,
IReadOnlyDictionary<string, string[]> source)
{
foreach (var entry in source)
{
target[entry.Key] = entry.Value;
}
}
}

View File

@@ -1,11 +1,17 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Localization;
using StellaOps.Remediation.Core.Abstractions;
using StellaOps.Remediation.Core.Services;
using StellaOps.Remediation.Persistence.Postgres;
using StellaOps.Remediation.Persistence.Repositories;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Remediation.WebService.Endpoints;
using StellaOps.Router.AspNet;
var builder = WebApplication.CreateBuilder(args);
var storageDriver = ResolveStorageDriver(builder.Configuration, "Remediation");
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
@@ -18,6 +24,7 @@ builder.Services.AddAuthorization(options =>
});
builder.Services.AddAuthentication();
builder.Services.AddStellaOpsTenantServices();
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
builder.Services.AddStellaOpsLocalization(builder.Configuration);
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
@@ -25,26 +32,28 @@ builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAss
builder.Services.AddSingleton<IContributorTrustScorer, ContributorTrustScorer>();
builder.Services.AddSingleton<IRemediationVerifier, RemediationVerifier>();
// Persistence (in-memory stubs for now; swap to Postgres in production)
var templateRepo = new PostgresFixTemplateRepository();
var submissionRepo = new PostgresPrSubmissionRepository();
builder.Services.AddSingleton<IFixTemplateRepository>(templateRepo);
builder.Services.AddSingleton<IPrSubmissionRepository>(submissionRepo);
RegisterPersistence(builder.Services, builder.Configuration, builder.Environment, storageDriver);
// Registry: compose from repositories
builder.Services.AddSingleton<IRemediationRegistry>(sp =>
new InMemoryRemediationRegistry(templateRepo, submissionRepo));
// Registry/matcher: compose from repositories.
builder.Services.AddSingleton<IRemediationRegistry, RepositoryBackedRemediationRegistry>();
builder.Services.AddSingleton<IRemediationMatcher, RepositoryBackedRemediationMatcher>();
// Matcher: compose from template repository
builder.Services.AddSingleton<IRemediationMatcher>(sp =>
new InMemoryRemediationMatcher(templateRepo));
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "remediation",
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
routerOptionsSection: "Router");
builder.TryAddStellaOpsLocalBinding("remediation");
var app = builder.Build();
app.LogStellaOpsLocalHostname("remediation");
app.UseAuthentication();
app.UseStellaOpsCors();
app.UseStellaOpsLocalization();
app.UseAuthorization();
app.UseStellaOpsTenantMiddleware();
app.TryUseStellaRouter(routerEnabled);
app.MapHealthChecks("/healthz").AllowAnonymous();
@@ -53,17 +62,112 @@ app.MapRemediationMatchEndpoints();
app.MapRemediationSourceEndpoints();
await app.LoadTranslationsAsync();
app.TryRefreshStellaRouterEndpoints(routerEnabled);
app.Run();
static void RegisterPersistence(
IServiceCollection services,
IConfiguration configuration,
IHostEnvironment environment,
string storageDriver)
{
if (string.Equals(storageDriver, "postgres", StringComparison.OrdinalIgnoreCase))
{
var connectionString = ResolvePostgresConnectionString(configuration, "Remediation");
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException(
"Remediation requires PostgreSQL connection settings when Storage:Driver=postgres. " +
"Set ConnectionStrings:Default or Remediation:Storage:Postgres:ConnectionString.");
}
var schemaName = ResolveSchemaName(configuration, "Remediation") ?? RemediationDataSource.DefaultSchemaName;
services.Configure<PostgresOptions>(options =>
{
options.ConnectionString = connectionString;
options.SchemaName = schemaName;
});
services.AddSingleton<RemediationDataSource>();
services.AddSingleton<IFixTemplateRepository, PostgresFixTemplateRepository>();
services.AddSingleton<IPrSubmissionRepository, PostgresPrSubmissionRepository>();
services.AddSingleton<IMarketplaceSourceRepository, PostgresMarketplaceSourceRepository>();
return;
}
if (string.Equals(storageDriver, "inmemory", StringComparison.OrdinalIgnoreCase))
{
if (!IsTestEnvironment(environment))
{
throw new InvalidOperationException(
"Remediation in-memory storage driver is restricted to Test/Testing environments.");
}
services.AddSingleton<IFixTemplateRepository>(_ => new PostgresFixTemplateRepository());
services.AddSingleton<IPrSubmissionRepository>(_ => new PostgresPrSubmissionRepository());
services.AddSingleton<IMarketplaceSourceRepository>(_ => new PostgresMarketplaceSourceRepository());
return;
}
throw new InvalidOperationException(
$"Unsupported Remediation storage driver '{storageDriver}'. Allowed values: postgres, inmemory.");
}
static bool IsTestEnvironment(IHostEnvironment environment)
{
return environment.IsEnvironment("Test") || environment.IsEnvironment("Testing");
}
static string ResolveStorageDriver(IConfiguration configuration, string serviceName)
{
return FirstNonEmpty(
configuration[$"{serviceName}:Storage:Driver"],
configuration["Storage:Driver"])
?? "postgres";
}
static string? ResolveSchemaName(IConfiguration configuration, string serviceName)
{
return FirstNonEmpty(
configuration[$"{serviceName}:Storage:Postgres:SchemaName"],
configuration["Storage:Postgres:SchemaName"],
configuration[$"Postgres:{serviceName}:SchemaName"]);
}
static string? ResolvePostgresConnectionString(IConfiguration configuration, string serviceName)
{
return FirstNonEmpty(
configuration[$"{serviceName}:Storage:Postgres:ConnectionString"],
configuration["Storage:Postgres:ConnectionString"],
configuration[$"Postgres:{serviceName}:ConnectionString"],
configuration[$"ConnectionStrings:{serviceName}"],
configuration["ConnectionStrings:Default"]);
}
static string? FirstNonEmpty(params string?[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
public partial class Program { }
/// <summary>
/// In-memory registry implementation composed from repositories.
/// Repository-backed registry implementation composed from repositories.
/// </summary>
internal sealed class InMemoryRemediationRegistry : IRemediationRegistry
internal sealed class RepositoryBackedRemediationRegistry : IRemediationRegistry
{
private readonly IFixTemplateRepository _templates;
private readonly IPrSubmissionRepository _submissions;
public InMemoryRemediationRegistry(IFixTemplateRepository templates, IPrSubmissionRepository submissions)
public RepositoryBackedRemediationRegistry(IFixTemplateRepository templates, IPrSubmissionRepository submissions)
{
_templates = templates;
_submissions = submissions;
@@ -92,13 +196,13 @@ internal sealed class InMemoryRemediationRegistry : IRemediationRegistry
}
/// <summary>
/// In-memory matcher implementation that delegates to template repository.
/// Repository-backed matcher implementation that delegates to template repository.
/// </summary>
internal sealed class InMemoryRemediationMatcher : IRemediationMatcher
internal sealed class RepositoryBackedRemediationMatcher : IRemediationMatcher
{
private readonly IFixTemplateRepository _templates;
public InMemoryRemediationMatcher(IFixTemplateRepository templates)
public RepositoryBackedRemediationMatcher(IFixTemplateRepository templates)
{
_templates = templates;
}

View File

@@ -8,6 +8,7 @@
<ProjectReference Include="..\StellaOps.Remediation.Core\StellaOps.Remediation.Core.csproj" />
<ProjectReference Include="..\StellaOps.Remediation.Persistence\StellaOps.Remediation.Persistence.csproj" />
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,107 @@
using StellaOps.Remediation.Core.Models;
using StellaOps.Remediation.Persistence.Repositories;
namespace StellaOps.Remediation.Tests;
public sealed class PostgresMarketplaceSourceRepositoryTests
{
[Fact]
public async Task UpsertAsync_IsIdempotentByTenantAndKey()
{
var repository = new PostgresMarketplaceSourceRepository();
var created = await repository.UpsertAsync("tenant-a", new MarketplaceSource
{
Key = "Vendor-Central",
Name = "Vendor Central",
Url = "https://vendor.example.com",
SourceType = "vendor",
Enabled = true,
TrustScore = 0.80
});
var updated = await repository.UpsertAsync("tenant-a", new MarketplaceSource
{
Key = "vendor-central",
Name = "Vendor Central Updated",
Url = "https://vendor.example.com/feeds",
SourceType = "vendor",
Enabled = false,
TrustScore = 0.92
});
var tenantAItems = await repository.ListAsync("tenant-a");
Assert.Equal(created.Id, updated.Id);
Assert.Equal("vendor-central", updated.Key);
Assert.Equal("Vendor Central Updated", updated.Name);
Assert.False(updated.Enabled);
Assert.Equal(0.92, updated.TrustScore);
Assert.Single(tenantAItems);
}
[Fact]
public async Task ListAsync_IsTenantIsolatedAndDeterministicallyOrderedByKey()
{
var repository = new PostgresMarketplaceSourceRepository();
await repository.UpsertAsync("tenant-a", new MarketplaceSource
{
Key = "zeta",
Name = "Zeta",
SourceType = "community",
Enabled = true,
TrustScore = 0.5
});
await repository.UpsertAsync("tenant-a", new MarketplaceSource
{
Key = "alpha",
Name = "Alpha",
SourceType = "community",
Enabled = true,
TrustScore = 0.5
});
await repository.UpsertAsync("tenant-b", new MarketplaceSource
{
Key = "alpha",
Name = "Alpha B",
SourceType = "partner",
Enabled = true,
TrustScore = 0.7
});
var tenantAItems = await repository.ListAsync("tenant-a");
var tenantBItems = await repository.ListAsync("tenant-b");
Assert.Equal(2, tenantAItems.Count);
Assert.Equal("alpha", tenantAItems[0].Key);
Assert.Equal("zeta", tenantAItems[1].Key);
Assert.Single(tenantBItems);
Assert.Equal("alpha", tenantBItems[0].Key);
Assert.Equal("Alpha B", tenantBItems[0].Name);
}
[Fact]
public async Task GetByKeyAsync_RespectsTenantIsolation()
{
var repository = new PostgresMarketplaceSourceRepository();
await repository.UpsertAsync("tenant-a", new MarketplaceSource
{
Key = "trusted-feed",
Name = "Trusted Feed",
SourceType = "partner",
Enabled = true,
TrustScore = 0.77
});
var tenantA = await repository.GetByKeyAsync("tenant-a", "trusted-feed");
var tenantB = await repository.GetByKeyAsync("tenant-b", "trusted-feed");
Assert.NotNull(tenantA);
Assert.Null(tenantB);
}
}

View File

@@ -0,0 +1,162 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.Remediation.WebService.Contracts;
namespace StellaOps.Remediation.WebService.Tests;
[Collection(RemediationEnvironmentCollection.Name)]
public sealed class RemediationSourceEndpointsTests
{
[Fact]
public async Task SourcesEndpoints_PostGetAndList_RoundTripWithPersistedData()
{
using var environment = RemediationEnvironmentScope.InMemoryTestingProfile();
await using var factory = CreateFactory();
using var client = CreateTenantClient(factory, "tenant-a");
var initialList = await client.GetFromJsonAsync<MarketplaceSourceListResponse>("/api/v1/remediation/sources");
Assert.NotNull(initialList);
Assert.Empty(initialList!.Items);
var request = new UpsertMarketplaceSourceRequest(
Key: "Vendor-Feed",
Name: "Vendor Feed",
Url: "https://vendor.example.com/feed",
SourceType: "vendor",
Enabled: true,
TrustScore: 0.91,
LastSyncAt: new DateTimeOffset(2026, 03, 04, 10, 0, 0, TimeSpan.Zero));
var postResponse = await client.PostAsJsonAsync("/api/v1/remediation/sources", request);
Assert.NotEqual(HttpStatusCode.NotImplemented, postResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, postResponse.StatusCode);
var posted = await postResponse.Content.ReadFromJsonAsync<MarketplaceSourceSummary>();
Assert.NotNull(posted);
Assert.Equal("vendor-feed", posted!.Key);
Assert.Equal("vendor", posted.SourceType);
var byKey = await client.GetFromJsonAsync<MarketplaceSourceSummary>("/api/v1/remediation/sources/vendor-feed");
Assert.NotNull(byKey);
Assert.Equal("Vendor Feed", byKey!.Name);
Assert.Equal(0.91, byKey.TrustScore);
var listed = await client.GetFromJsonAsync<MarketplaceSourceListResponse>("/api/v1/remediation/sources");
Assert.NotNull(listed);
Assert.Single(listed!.Items);
Assert.Equal("vendor-feed", listed.Items[0].Key);
}
[Fact]
public async Task SourcesEndpoints_EnforceTenantIsolation()
{
using var environment = RemediationEnvironmentScope.InMemoryTestingProfile();
await using var factory = CreateFactory();
using var tenantAClient = CreateTenantClient(factory, "tenant-a");
using var tenantBClient = CreateTenantClient(factory, "tenant-b");
var request = new UpsertMarketplaceSourceRequest(
Key: "shared-key",
Name: "Tenant A Source",
Url: "https://a.example.com",
SourceType: "community",
Enabled: true,
TrustScore: 0.5,
LastSyncAt: null);
var post = await tenantAClient.PostAsJsonAsync("/api/v1/remediation/sources", request);
post.EnsureSuccessStatusCode();
var tenantAList = await tenantAClient.GetFromJsonAsync<MarketplaceSourceListResponse>("/api/v1/remediation/sources");
var tenantBList = await tenantBClient.GetFromJsonAsync<MarketplaceSourceListResponse>("/api/v1/remediation/sources");
Assert.NotNull(tenantAList);
Assert.NotNull(tenantBList);
Assert.Single(tenantAList!.Items);
Assert.Empty(tenantBList!.Items);
var tenantBGet = await tenantBClient.GetAsync("/api/v1/remediation/sources/shared-key");
Assert.Equal(HttpStatusCode.NotFound, tenantBGet.StatusCode);
}
[Fact]
public async Task SourcesEndpoints_ListOrderingAndUpsertAreDeterministic()
{
using var environment = RemediationEnvironmentScope.InMemoryTestingProfile();
await using var factory = CreateFactory();
using var client = CreateTenantClient(factory, "tenant-ordering");
await UpsertAsync(client, "zeta", "Zeta", 0.2);
await UpsertAsync(client, "alpha", "Alpha", 0.3);
await UpsertAsync(client, "beta", "Beta", 0.4);
await UpsertAsync(client, "alpha", "Alpha Updated", 0.9);
var listed = await client.GetFromJsonAsync<MarketplaceSourceListResponse>("/api/v1/remediation/sources");
Assert.NotNull(listed);
Assert.Equal(3, listed!.Count);
Assert.Equal(["alpha", "beta", "zeta"], listed.Items.Select(static item => item.Key).ToArray());
Assert.Equal("Alpha Updated", listed.Items[0].Name);
Assert.Equal(0.9, listed.Items[0].TrustScore);
}
[Fact]
public async Task SourcesEndpoints_ReturnDeterministicValidationProblems()
{
using var environment = RemediationEnvironmentScope.InMemoryTestingProfile();
await using var factory = CreateFactory();
using var client = CreateTenantClient(factory, "tenant-validation");
var request = new UpsertMarketplaceSourceRequest(
Key: "Invalid Key",
Name: "",
Url: "not-a-url",
SourceType: "unknown",
Enabled: true,
TrustScore: 1.5,
LastSyncAt: null);
var response = await client.PostAsJsonAsync("/api/v1/remediation/sources", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<HttpValidationProblemDetails>();
Assert.NotNull(problem);
Assert.Equal(StatusCodes.Status400BadRequest, problem!.Status);
Assert.Contains("key", problem.Errors.Keys);
Assert.Contains("name", problem.Errors.Keys);
Assert.Contains("sourceType", problem.Errors.Keys);
Assert.Contains("trustScore", problem.Errors.Keys);
Assert.Contains("url", problem.Errors.Keys);
}
private static WebApplicationFactory<Program> CreateFactory()
{
return new WebApplicationFactory<Program>();
}
private static HttpClient CreateTenantClient(WebApplicationFactory<Program> factory, string tenantId)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "remediation-source-tests");
return client;
}
private static async Task UpsertAsync(HttpClient client, string key, string name, double trustScore)
{
var response = await client.PostAsJsonAsync(
"/api/v1/remediation/sources",
new UpsertMarketplaceSourceRequest(
Key: key,
Name: name,
Url: $"https://example.com/{key}",
SourceType: "community",
Enabled: true,
TrustScore: trustScore,
LastSyncAt: null));
response.EnsureSuccessStatusCode();
}
}

View File

@@ -0,0 +1,68 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
namespace StellaOps.Remediation.WebService.Tests;
[Collection(RemediationEnvironmentCollection.Name)]
public sealed class RemediationStartupContractTests
{
[Fact]
public void Startup_FailsWithoutPostgresConnectionString_WhenPostgresDriverSelected()
{
using var environment = RemediationEnvironmentScope.PostgresWithoutConnection("Production");
using var factory = new WebApplicationFactory<Program>();
var exception = Assert.ThrowsAny<Exception>(() =>
{
using var client = factory.CreateClient();
});
Assert.Contains(
"Remediation requires PostgreSQL connection settings when Storage:Driver=postgres.",
exception.ToString(),
StringComparison.Ordinal);
}
[Fact]
public void Startup_RejectsInMemoryDriver_OutsideTestProfiles()
{
using var environment = RemediationEnvironmentScope.InMemoryDriver("Production");
using var factory = new WebApplicationFactory<Program>();
var exception = Assert.ThrowsAny<Exception>(() =>
{
using var client = factory.CreateClient();
});
Assert.Contains(
"Remediation in-memory storage driver is restricted to Test/Testing environments.",
exception.ToString(),
StringComparison.Ordinal);
}
[Fact]
public async Task Startup_AllowsInMemoryDriver_InTestingProfile()
{
using var environment = RemediationEnvironmentScope.InMemoryTestingProfile();
using var factory = new WebApplicationFactory<Program>();
using var client = factory.CreateClient();
var response = await client.GetAsync("/healthz");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Startup_AllowsPostgresDriver_WhenConnectionStringIsConfigured()
{
using var environment = RemediationEnvironmentScope.PostgresWithConnection(
"Production",
"Host=localhost;Database=stellaops_remediation;Username=stellaops;Password=stellaops");
using var factory = new WebApplicationFactory<Program>();
using var client = factory.CreateClient();
var response = await client.GetAsync("/healthz");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Remediation.WebService\StellaOps.Remediation.WebService.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,88 @@
namespace StellaOps.Remediation.WebService.Tests;
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class RemediationEnvironmentCollection
{
public const string Name = "RemediationEnvironment";
}
internal sealed class RemediationEnvironmentScope : IDisposable
{
private static readonly string[] ManagedKeys =
[
"DOTNET_ENVIRONMENT",
"ASPNETCORE_ENVIRONMENT",
"REMEDIATION__STORAGE__DRIVER",
"REMEDIATION__STORAGE__POSTGRES__CONNECTIONSTRING",
"CONNECTIONSTRINGS__DEFAULT"
];
private readonly Dictionary<string, string?> _originalValues = new(StringComparer.Ordinal);
private RemediationEnvironmentScope()
{
foreach (var key in ManagedKeys)
{
_originalValues[key] = Environment.GetEnvironmentVariable(key);
}
}
public static RemediationEnvironmentScope InMemoryTestingProfile()
{
var scope = new RemediationEnvironmentScope();
scope.Set("DOTNET_ENVIRONMENT", "Testing");
scope.Set("ASPNETCORE_ENVIRONMENT", "Testing");
scope.Set("REMEDIATION__STORAGE__DRIVER", "inmemory");
scope.Set("REMEDIATION__STORAGE__POSTGRES__CONNECTIONSTRING", null);
scope.Set("CONNECTIONSTRINGS__DEFAULT", null);
return scope;
}
public static RemediationEnvironmentScope PostgresWithoutConnection(string environmentName)
{
var scope = new RemediationEnvironmentScope();
scope.Set("DOTNET_ENVIRONMENT", environmentName);
scope.Set("ASPNETCORE_ENVIRONMENT", environmentName);
scope.Set("REMEDIATION__STORAGE__DRIVER", "postgres");
scope.Set("REMEDIATION__STORAGE__POSTGRES__CONNECTIONSTRING", null);
scope.Set("CONNECTIONSTRINGS__DEFAULT", null);
return scope;
}
public static RemediationEnvironmentScope InMemoryDriver(string environmentName)
{
var scope = new RemediationEnvironmentScope();
scope.Set("DOTNET_ENVIRONMENT", environmentName);
scope.Set("ASPNETCORE_ENVIRONMENT", environmentName);
scope.Set("REMEDIATION__STORAGE__DRIVER", "inmemory");
scope.Set("REMEDIATION__STORAGE__POSTGRES__CONNECTIONSTRING", null);
scope.Set("CONNECTIONSTRINGS__DEFAULT", null);
return scope;
}
public static RemediationEnvironmentScope PostgresWithConnection(
string environmentName,
string connectionString)
{
var scope = new RemediationEnvironmentScope();
scope.Set("DOTNET_ENVIRONMENT", environmentName);
scope.Set("ASPNETCORE_ENVIRONMENT", environmentName);
scope.Set("REMEDIATION__STORAGE__DRIVER", "postgres");
scope.Set("REMEDIATION__STORAGE__POSTGRES__CONNECTIONSTRING", connectionString);
scope.Set("CONNECTIONSTRINGS__DEFAULT", connectionString);
return scope;
}
public void Dispose()
{
foreach (var entry in _originalValues)
{
Environment.SetEnvironmentVariable(entry.Key, entry.Value);
}
}
private void Set(string key, string? value)
{
Environment.SetEnvironmentVariable(key, value);
}
}