consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
28
src/Remediation/AGENTS.md
Normal file
28
src/Remediation/AGENTS.md
Normal 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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user