Gaps fill up, fixes, ui restructuring
This commit is contained in:
@@ -121,6 +121,18 @@ public static class IntegrationEndpoints
|
||||
.WithName("CheckIntegrationHealth")
|
||||
.WithDescription("Performs a health check on an integration.");
|
||||
|
||||
// Impact map
|
||||
group.MapGet("/{id:guid}/impact", async (
|
||||
[FromServices] IntegrationService service,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.GetImpactAsync(id, cancellationToken);
|
||||
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||
})
|
||||
.WithName("GetIntegrationImpact")
|
||||
.WithDescription("Returns affected workflows and severity impact for an integration.");
|
||||
|
||||
// Get supported providers
|
||||
group.MapGet("/providers", ([FromServices] IntegrationService service) =>
|
||||
{
|
||||
|
||||
@@ -269,6 +269,31 @@ public sealed class IntegrationService
|
||||
result.Duration);
|
||||
}
|
||||
|
||||
public async Task<IntegrationImpactResponse?> GetImpactAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var integration = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
if (integration is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var impactedWorkflows = BuildImpactedWorkflows(integration)
|
||||
.OrderBy(workflow => workflow.Workflow, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var blockingCount = impactedWorkflows.Count(workflow => workflow.Blocking);
|
||||
|
||||
return new IntegrationImpactResponse(
|
||||
IntegrationId: integration.Id,
|
||||
IntegrationName: integration.Name,
|
||||
Type: integration.Type,
|
||||
Provider: integration.Provider,
|
||||
Status: integration.Status,
|
||||
Severity: DetermineSeverity(integration.Status, blockingCount),
|
||||
BlockingWorkflowCount: blockingCount,
|
||||
TotalWorkflowCount: impactedWorkflows.Count,
|
||||
ImpactedWorkflows: impactedWorkflows);
|
||||
}
|
||||
|
||||
public IReadOnlyList<ProviderInfo> GetSupportedProviders()
|
||||
{
|
||||
return _pluginLoader.Plugins.Select(p => new ProviderInfo(
|
||||
@@ -277,6 +302,55 @@ public sealed class IntegrationService
|
||||
p.Provider)).ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ImpactedWorkflow> BuildImpactedWorkflows(Integration integration)
|
||||
{
|
||||
var blockedByStatus = integration.Status is IntegrationStatus.Failed or IntegrationStatus.Disabled or IntegrationStatus.Archived;
|
||||
return integration.Type switch
|
||||
{
|
||||
IntegrationType.Registry =>
|
||||
[
|
||||
new ImpactedWorkflow("bundle-materialization", "release-control", blockedByStatus, "Container digest fetch and verification path.", "restore-registry-connectivity"),
|
||||
new ImpactedWorkflow("sbom-attachment", "evidence", blockedByStatus, "SBOM/image digest correlation during pack generation.", "re-run-bundle-sync"),
|
||||
],
|
||||
IntegrationType.Scm =>
|
||||
[
|
||||
new ImpactedWorkflow("bundle-changelog", "release-control", blockedByStatus, "Repository changelog enrichment for bundle versions.", "reconnect-scm-app"),
|
||||
new ImpactedWorkflow("policy-drift-audit", "administration", blockedByStatus, "Policy governance change audit extraction.", "refresh-scm-access-token"),
|
||||
],
|
||||
IntegrationType.CiCd =>
|
||||
[
|
||||
new ImpactedWorkflow("promotion-preflight", "release-control", blockedByStatus, "Deployment signal and gate preflight signal stream.", "revalidate-ci-runner-credentials"),
|
||||
new ImpactedWorkflow("ops-job-health", "platform-ops", blockedByStatus, "Pipeline lag/health insights for nightly report.", "replay-latest-ci-webhooks"),
|
||||
],
|
||||
IntegrationType.RepoSource =>
|
||||
[
|
||||
new ImpactedWorkflow("dependency-resolution", "security-risk", blockedByStatus, "Package advisory resolution and normalization.", "verify-repository-mirror"),
|
||||
new ImpactedWorkflow("hot-lookup-projection", "security-risk", blockedByStatus, "Hot-lookup enrichment for findings explorer.", "resync-package-index"),
|
||||
],
|
||||
IntegrationType.RuntimeHost =>
|
||||
[
|
||||
new ImpactedWorkflow("runtime-reachability", "security-risk", blockedByStatus, "Runtime witness ingestion for reachability confidence.", "restart-runtime-agent"),
|
||||
new ImpactedWorkflow("ops-confidence", "platform-ops", blockedByStatus, "Data-confidence score for approvals and dashboard.", "clear-runtime-dlq"),
|
||||
],
|
||||
IntegrationType.FeedMirror =>
|
||||
[
|
||||
new ImpactedWorkflow("advisory-freshness", "security-risk", blockedByStatus, "Advisory source freshness and conflict views.", "refresh-feed-mirror"),
|
||||
new ImpactedWorkflow("rescore-pipeline", "platform-ops", blockedByStatus, "Nightly rescoring jobs and stale SBOM remediation.", "trigger-feed-replay"),
|
||||
],
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
private static string DetermineSeverity(IntegrationStatus status, int blockingCount)
|
||||
{
|
||||
if (status is IntegrationStatus.Failed or IntegrationStatus.Disabled or IntegrationStatus.Archived)
|
||||
{
|
||||
return "high";
|
||||
}
|
||||
|
||||
return blockingCount > 0 ? "medium" : "low";
|
||||
}
|
||||
|
||||
private static IntegrationConfig BuildConfig(Integration integration, string? resolvedSecret)
|
||||
{
|
||||
IReadOnlyDictionary<string, object>? extendedConfig = null;
|
||||
@@ -321,3 +395,21 @@ public sealed class IntegrationService
|
||||
/// Information about a supported provider.
|
||||
/// </summary>
|
||||
public sealed record ProviderInfo(string Name, IntegrationType Type, IntegrationProvider Provider);
|
||||
|
||||
public sealed record IntegrationImpactResponse(
|
||||
Guid IntegrationId,
|
||||
string IntegrationName,
|
||||
IntegrationType Type,
|
||||
IntegrationProvider Provider,
|
||||
IntegrationStatus Status,
|
||||
string Severity,
|
||||
int BlockingWorkflowCount,
|
||||
int TotalWorkflowCount,
|
||||
IReadOnlyList<ImpactedWorkflow> ImpactedWorkflows);
|
||||
|
||||
public sealed record ImpactedWorkflow(
|
||||
string Workflow,
|
||||
string Domain,
|
||||
bool Blocking,
|
||||
string Impact,
|
||||
string RecommendedAction);
|
||||
|
||||
@@ -21,7 +21,13 @@ public enum IntegrationType
|
||||
RuntimeHost = 5,
|
||||
|
||||
/// <summary>Advisory/vulnerability feed mirror.</summary>
|
||||
FeedMirror = 6
|
||||
FeedMirror = 6,
|
||||
|
||||
/// <summary>Symbol/debug pack source (Microsoft Symbols, debuginfod, partner feeds).</summary>
|
||||
SymbolSource = 7,
|
||||
|
||||
/// <summary>Remediation marketplace source (community, partner, vendor fix templates).</summary>
|
||||
Marketplace = 8
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -75,6 +81,18 @@ public enum IntegrationProvider
|
||||
NvdMirror = 601,
|
||||
OsvMirror = 602,
|
||||
|
||||
// Symbol sources
|
||||
MicrosoftSymbols = 700,
|
||||
UbuntuDebuginfod = 701,
|
||||
FedoraDebuginfod = 702,
|
||||
DebianDebuginfod = 703,
|
||||
PartnerSymbols = 704,
|
||||
|
||||
// Marketplace sources
|
||||
CommunityFixes = 800,
|
||||
PartnerFixes = 801,
|
||||
VendorFixes = 802,
|
||||
|
||||
// Generic / testing
|
||||
InMemory = 900,
|
||||
Custom = 999
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Core;
|
||||
using StellaOps.Integrations.Persistence;
|
||||
using StellaOps.Integrations.WebService;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Integrations.Tests;
|
||||
|
||||
public sealed class IntegrationImpactEndpointsTests : IClassFixture<IntegrationImpactWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public IntegrationImpactEndpointsTests(IntegrationImpactWebApplicationFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ImpactEndpoint_ReturnsDeterministicWorkflowMap()
|
||||
{
|
||||
var createRequest = new CreateIntegrationRequest(
|
||||
Name: $"NVD Mirror {Guid.NewGuid():N}",
|
||||
Description: "Feed mirror",
|
||||
Type: IntegrationType.FeedMirror,
|
||||
Provider: IntegrationProvider.NvdMirror,
|
||||
Endpoint: "https://feeds.example.local/nvd",
|
||||
AuthRefUri: null,
|
||||
OrganizationId: null,
|
||||
ExtendedConfig: null,
|
||||
Tags: ["feed"]);
|
||||
|
||||
var createResponse = await _client.PostAsJsonAsync(
|
||||
"/api/v1/integrations/",
|
||||
createRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<IntegrationResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
|
||||
var first = await _client.GetFromJsonAsync<IntegrationImpactResponse>(
|
||||
$"/api/v1/integrations/{created!.Id}/impact",
|
||||
TestContext.Current.CancellationToken);
|
||||
var second = await _client.GetFromJsonAsync<IntegrationImpactResponse>(
|
||||
$"/api/v1/integrations/{created.Id}/impact",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.NotNull(second);
|
||||
Assert.Equal(first!.IntegrationId, second!.IntegrationId);
|
||||
Assert.Equal(first.IntegrationName, second.IntegrationName);
|
||||
Assert.Equal(first.Type, second.Type);
|
||||
Assert.Equal(first.Provider, second.Provider);
|
||||
Assert.Equal(first.Status, second.Status);
|
||||
Assert.Equal(first.Severity, second.Severity);
|
||||
Assert.Equal(first.BlockingWorkflowCount, second.BlockingWorkflowCount);
|
||||
Assert.Equal(first.TotalWorkflowCount, second.TotalWorkflowCount);
|
||||
Assert.Equal(
|
||||
first.ImpactedWorkflows.Select(workflow => workflow.Workflow).ToArray(),
|
||||
second.ImpactedWorkflows.Select(workflow => workflow.Workflow).ToArray());
|
||||
Assert.Equal("low", first!.Severity);
|
||||
Assert.Equal(0, first.BlockingWorkflowCount);
|
||||
|
||||
var ordered = first.ImpactedWorkflows
|
||||
.Select(workflow => workflow.Workflow)
|
||||
.OrderBy(workflow => workflow, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
Assert.Equal(ordered, first.ImpactedWorkflows.Select(workflow => workflow.Workflow).ToArray());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ImpactEndpoint_WithUnknownId_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/v1/integrations/{Guid.NewGuid()}/impact",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class IntegrationImpactWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IIntegrationRepository>();
|
||||
services.AddSingleton<IIntegrationRepository, InMemoryIntegrationRepository>();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryIntegrationRepository : IIntegrationRepository
|
||||
{
|
||||
private readonly Dictionary<Guid, Integration> _items = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
public Task<Integration?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_items.TryGetValue(id, out var value);
|
||||
return Task.FromResult<Integration?>(value is null ? null : Clone(value));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Integration>> GetAllAsync(IntegrationQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
IEnumerable<Integration> values = _items.Values;
|
||||
|
||||
if (!query.IncludeDeleted)
|
||||
{
|
||||
values = values.Where(item => !item.IsDeleted);
|
||||
}
|
||||
|
||||
if (query.Type.HasValue)
|
||||
{
|
||||
values = values.Where(item => item.Type == query.Type.Value);
|
||||
}
|
||||
|
||||
if (query.Provider.HasValue)
|
||||
{
|
||||
values = values.Where(item => item.Provider == query.Provider.Value);
|
||||
}
|
||||
|
||||
if (query.Status.HasValue)
|
||||
{
|
||||
values = values.Where(item => item.Status == query.Status.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.TenantId))
|
||||
{
|
||||
values = values.Where(item => string.Equals(item.TenantId, query.TenantId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
values = values.Where(item =>
|
||||
item.Name.Contains(query.Search, StringComparison.OrdinalIgnoreCase) ||
|
||||
(item.Description?.Contains(query.Search, StringComparison.OrdinalIgnoreCase) ?? false));
|
||||
}
|
||||
|
||||
values = values.OrderBy(item => item.Name, StringComparer.Ordinal)
|
||||
.Skip(Math.Max(0, query.Skip))
|
||||
.Take(Math.Max(1, query.Take));
|
||||
|
||||
return Task.FromResult<IReadOnlyList<Integration>>(values.Select(Clone).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public Task<int> CountAsync(IntegrationQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return Task.FromResult(_items.Values.Count(item => query.IncludeDeleted || !item.IsDeleted));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Integration> CreateAsync(Integration integration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_items[integration.Id] = Clone(integration);
|
||||
return Task.FromResult(Clone(integration));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Integration> UpdateAsync(Integration integration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_items[integration.Id] = Clone(integration);
|
||||
return Task.FromResult(Clone(integration));
|
||||
}
|
||||
}
|
||||
|
||||
public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_items.TryGetValue(id, out var existing))
|
||||
{
|
||||
existing.IsDeleted = true;
|
||||
existing.Status = IntegrationStatus.Archived;
|
||||
_items[id] = existing;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Integration>> GetByProviderAsync(IntegrationProvider provider, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var items = _items.Values
|
||||
.Where(item => item.Provider == provider && !item.IsDeleted)
|
||||
.OrderBy(item => item.Name, StringComparer.Ordinal)
|
||||
.Select(Clone)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<Integration>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Integration>> GetActiveByTypeAsync(IntegrationType type, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var items = _items.Values
|
||||
.Where(item => item.Type == type && item.Status == IntegrationStatus.Active && !item.IsDeleted)
|
||||
.OrderBy(item => item.Name, StringComparer.Ordinal)
|
||||
.Select(Clone)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<Integration>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
public Task UpdateHealthStatusAsync(Guid id, HealthStatus status, DateTimeOffset checkedAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_items.TryGetValue(id, out var existing))
|
||||
{
|
||||
existing.LastHealthStatus = status;
|
||||
existing.LastHealthCheckAt = checkedAt;
|
||||
_items[id] = existing;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Integration Clone(Integration source)
|
||||
{
|
||||
return new Integration
|
||||
{
|
||||
Id = source.Id,
|
||||
Name = source.Name,
|
||||
Description = source.Description,
|
||||
Type = source.Type,
|
||||
Provider = source.Provider,
|
||||
Status = source.Status,
|
||||
Endpoint = source.Endpoint,
|
||||
AuthRefUri = source.AuthRefUri,
|
||||
OrganizationId = source.OrganizationId,
|
||||
ConfigJson = source.ConfigJson,
|
||||
LastHealthStatus = source.LastHealthStatus,
|
||||
LastHealthCheckAt = source.LastHealthCheckAt,
|
||||
CreatedAt = source.CreatedAt,
|
||||
UpdatedAt = source.UpdatedAt,
|
||||
CreatedBy = source.CreatedBy,
|
||||
UpdatedBy = source.UpdatedBy,
|
||||
TenantId = source.TenantId,
|
||||
Tags = source.Tags.ToList(),
|
||||
IsDeleted = source.IsDeleted
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,50 @@ public class IntegrationServiceTests
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task GetImpactAsync_WithNonExistingIntegration_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var id = Guid.NewGuid();
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Integration?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetImpactAsync(id);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task GetImpactAsync_WithFailedFeedMirror_ReturnsBlockingHighSeverity()
|
||||
{
|
||||
// Arrange
|
||||
var integration = CreateTestIntegration(
|
||||
type: IntegrationType.FeedMirror,
|
||||
provider: IntegrationProvider.NvdMirror);
|
||||
integration.Status = IntegrationStatus.Failed;
|
||||
integration.Name = "NVD Mirror";
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetImpactAsync(integration.Id);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Severity.Should().Be("high");
|
||||
result.BlockingWorkflowCount.Should().Be(result.TotalWorkflowCount);
|
||||
result.ImpactedWorkflows.Should().HaveCount(2);
|
||||
result.ImpactedWorkflows.Select(workflow => workflow.Workflow)
|
||||
.Should().BeInAscendingOrder(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static Integration CreateTestIntegration(
|
||||
IntegrationType type = IntegrationType.Registry,
|
||||
IntegrationProvider provider = IntegrationProvider.Harbor)
|
||||
|
||||
Reference in New Issue
Block a user