Gaps fill up, fixes, ui restructuring
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Claims;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Encodings.Web;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Models;
|
||||
using StellaOps.Concelier.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Concelier.WebService.Extensions;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests;
|
||||
|
||||
public sealed class AdvisorySourceWebAppFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
public AdvisorySourceWebAppFactory()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CONCELIER__POSTGRESSTORAGE__CONNECTIONSTRING", "Host=localhost;Port=5432;Database=test-advisory-sources");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__POSTGRESSTORAGE__COMMANDTIMEOUTSECONDS", "30");
|
||||
Environment.SetEnvironmentVariable("CONCELIER__TELEMETRY__ENABLED", "false");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
|
||||
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "Host=localhost;Port=5432;Database=test-advisory-sources");
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
var overrides = new Dictionary<string, string?>
|
||||
{
|
||||
{ "PostgresStorage:ConnectionString", "Host=localhost;Port=5432;Database=test-advisory-sources" },
|
||||
{ "PostgresStorage:CommandTimeoutSeconds", "30" },
|
||||
{ "Telemetry:Enabled", "false" }
|
||||
};
|
||||
|
||||
config.AddInMemoryCollection(overrides);
|
||||
});
|
||||
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, static _ => { });
|
||||
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
// Endpoint behavior in this test suite focuses on tenant/header/repository behavior.
|
||||
// Authorization policy is exercised in dedicated auth coverage tests.
|
||||
options.AddPolicy("Concelier.Advisories.Read", policy => policy.RequireAssertion(static _ => true));
|
||||
});
|
||||
|
||||
services.RemoveAll<ILeaseStore>();
|
||||
services.AddSingleton<ILeaseStore, Fixtures.TestLeaseStore>();
|
||||
|
||||
services.RemoveAll<IAdvisorySourceReadRepository>();
|
||||
services.AddSingleton<IAdvisorySourceReadRepository, StubAdvisorySourceReadRepository>();
|
||||
|
||||
services.RemoveAll<ISourceRepository>();
|
||||
services.AddSingleton<ISourceRepository, StubSourceRepository>();
|
||||
|
||||
services.AddSingleton<ConcelierOptions>(new ConcelierOptions
|
||||
{
|
||||
PostgresStorage = new ConcelierOptions.PostgresStorageOptions
|
||||
{
|
||||
ConnectionString = "Host=localhost;Port=5432;Database=test-advisory-sources",
|
||||
CommandTimeoutSeconds = 30
|
||||
},
|
||||
Telemetry = new ConcelierOptions.TelemetryOptions
|
||||
{
|
||||
Enabled = false
|
||||
}
|
||||
});
|
||||
|
||||
services.AddSingleton<IConfigureOptions<ConcelierOptions>>(_ => new ConfigureOptions<ConcelierOptions>(opts =>
|
||||
{
|
||||
opts.PostgresStorage ??= new ConcelierOptions.PostgresStorageOptions();
|
||||
opts.PostgresStorage.ConnectionString = "Host=localhost;Port=5432;Database=test-advisory-sources";
|
||||
opts.PostgresStorage.CommandTimeoutSeconds = 30;
|
||||
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
|
||||
opts.Telemetry.Enabled = false;
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "AdvisorySourceTests";
|
||||
|
||||
public TestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "advisory-source-tests")
|
||||
};
|
||||
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
|
||||
var ticket = new AuthenticationTicket(principal, SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubAdvisorySourceReadRepository : IAdvisorySourceReadRepository
|
||||
{
|
||||
private static readonly AdvisorySourceFreshnessRecord[] Records =
|
||||
[
|
||||
new(
|
||||
SourceId: Guid.Parse("0e94e643-4045-4af8-b0c4-f4f04f769279"),
|
||||
SourceKey: "nvd",
|
||||
SourceName: "NVD",
|
||||
SourceFamily: "nvd",
|
||||
SourceUrl: "https://nvd.nist.gov",
|
||||
Priority: 100,
|
||||
Enabled: true,
|
||||
LastSyncAt: DateTimeOffset.Parse("2026-02-19T08:11:00Z"),
|
||||
LastSuccessAt: DateTimeOffset.Parse("2026-02-19T08:10:00Z"),
|
||||
LastError: null,
|
||||
SyncCount: 220,
|
||||
ErrorCount: 1,
|
||||
FreshnessSlaSeconds: 14400,
|
||||
WarningRatio: 0.8m,
|
||||
FreshnessAgeSeconds: 3600,
|
||||
FreshnessStatus: "healthy",
|
||||
SignatureStatus: "signed",
|
||||
TotalAdvisories: 220,
|
||||
SignedAdvisories: 215,
|
||||
UnsignedAdvisories: 5,
|
||||
SignatureFailureCount: 1),
|
||||
new(
|
||||
SourceId: Guid.Parse("fc9d6356-01d8-4012-8ce7-31e0f983f8c3"),
|
||||
SourceKey: "ghsa",
|
||||
SourceName: "GHSA",
|
||||
SourceFamily: "ghsa",
|
||||
SourceUrl: "https://github.com/advisories",
|
||||
Priority: 80,
|
||||
Enabled: false,
|
||||
LastSyncAt: DateTimeOffset.Parse("2026-02-19T01:00:00Z"),
|
||||
LastSuccessAt: DateTimeOffset.Parse("2026-02-18T20:30:00Z"),
|
||||
LastError: "timeout",
|
||||
SyncCount: 200,
|
||||
ErrorCount: 8,
|
||||
FreshnessSlaSeconds: 14400,
|
||||
WarningRatio: 0.8m,
|
||||
FreshnessAgeSeconds: 43200,
|
||||
FreshnessStatus: "stale",
|
||||
SignatureStatus: "unsigned",
|
||||
TotalAdvisories: 200,
|
||||
SignedAdvisories: 0,
|
||||
UnsignedAdvisories: 200,
|
||||
SignatureFailureCount: 0)
|
||||
];
|
||||
|
||||
public Task<IReadOnlyList<AdvisorySourceFreshnessRecord>> ListAsync(
|
||||
bool includeDisabled = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
IReadOnlyList<AdvisorySourceFreshnessRecord> items = includeDisabled
|
||||
? Records
|
||||
: Records.Where(static record => record.Enabled).ToList();
|
||||
return Task.FromResult(items);
|
||||
}
|
||||
|
||||
public Task<AdvisorySourceFreshnessRecord?> GetBySourceIdAsync(
|
||||
Guid sourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(Records.FirstOrDefault(record => record.SourceId == sourceId));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubSourceRepository : ISourceRepository
|
||||
{
|
||||
private static readonly IReadOnlyList<SourceEntity> Sources =
|
||||
[
|
||||
new SourceEntity
|
||||
{
|
||||
Id = Guid.Parse("0e94e643-4045-4af8-b0c4-f4f04f769279"),
|
||||
Key = "nvd",
|
||||
Name = "NVD",
|
||||
SourceType = "nvd",
|
||||
Url = "https://nvd.nist.gov",
|
||||
Priority = 100,
|
||||
Enabled = true,
|
||||
CreatedAt = DateTimeOffset.Parse("2026-02-18T00:00:00Z"),
|
||||
UpdatedAt = DateTimeOffset.Parse("2026-02-19T00:00:00Z")
|
||||
}
|
||||
];
|
||||
|
||||
public Task<SourceEntity> UpsertAsync(SourceEntity source, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(source);
|
||||
|
||||
public Task<SourceEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Sources.FirstOrDefault(source => source.Id == id));
|
||||
|
||||
public Task<SourceEntity?> GetByKeyAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Sources.FirstOrDefault(source => string.Equals(source.Key, key, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
public Task<IReadOnlyList<SourceEntity>> ListAsync(bool? enabled = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = Sources
|
||||
.Where(source => enabled is null || source.Enabled == enabled.Value)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<SourceEntity>>(items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AdvisorySourceEndpointsTests : IClassFixture<AdvisorySourceWebAppFactory>
|
||||
{
|
||||
private readonly AdvisorySourceWebAppFactory _factory;
|
||||
|
||||
public AdvisorySourceEndpointsTests(AdvisorySourceWebAppFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ListEndpoints_WithoutTenantHeader_ReturnsBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources", CancellationToken.None);
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ListEndpoints_WithTenantHeader_ReturnsRecords()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources?includeDisabled=true", CancellationToken.None);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceListResponse>(cancellationToken: CancellationToken.None);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(2, payload!.TotalCount);
|
||||
Assert.Contains(payload.Items, static item => item.SourceKey == "nvd");
|
||||
Assert.Contains(payload.Items, static item => item.SourceKey == "ghsa");
|
||||
var nvd = payload.Items.Single(static item => item.SourceKey == "nvd");
|
||||
Assert.Equal(220, nvd.TotalAdvisories);
|
||||
Assert.Equal(215, nvd.SignedAdvisories);
|
||||
Assert.Equal(5, nvd.UnsignedAdvisories);
|
||||
Assert.Equal(1, nvd.SignatureFailureCount);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SummaryEndpoint_ReturnsExpectedCounts()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources/summary", CancellationToken.None);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceSummaryResponse>(cancellationToken: CancellationToken.None);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(2, payload!.TotalSources);
|
||||
Assert.Equal(1, payload.HealthySources);
|
||||
Assert.Equal(1, payload.StaleSources);
|
||||
Assert.Equal(1, payload.DisabledSources);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FreshnessEndpoint_ByKey_ReturnsRecord()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources/nvd/freshness", CancellationToken.None);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisorySourceFreshnessResponse>(cancellationToken: CancellationToken.None);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("nvd", payload!.Source.SourceKey);
|
||||
Assert.Equal("healthy", payload.Source.FreshnessStatus);
|
||||
Assert.Equal(220, payload.Source.TotalAdvisories);
|
||||
Assert.Equal(215, payload.Source.SignedAdvisories);
|
||||
Assert.Equal(5, payload.Source.UnsignedAdvisories);
|
||||
Assert.Equal(1, payload.Source.SignatureFailureCount);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FreshnessEndpoint_UnknownSource_ReturnsNotFound()
|
||||
{
|
||||
using var client = CreateTenantClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/advisory-sources/unknown-source/freshness", CancellationToken.None);
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-a");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0243-M | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0243-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0243-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| BE8-07-TEST | DONE | Extended advisory-source endpoint coverage for total/signed/unsigned/signature-failure contract fields. |
|
||||
|
||||
Reference in New Issue
Block a user