Widen scratch iteration 011 with fixture-backed integrations QA
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
using static StellaOps.Localization.T;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Integrations.Contracts;
|
||||
using StellaOps.Integrations.Contracts.AiCodeGuard;
|
||||
@@ -59,10 +61,11 @@ public static class IntegrationEndpoints
|
||||
// Get integration by ID
|
||||
group.MapGet("/{id:guid}", async (
|
||||
[FromServices] IntegrationService service,
|
||||
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.GetByIdAsync(id, cancellationToken);
|
||||
var result = await service.GetByIdAsync(id, tenantAccessor.TenantId, cancellationToken);
|
||||
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||
})
|
||||
.RequireAuthorization(IntegrationPolicies.Read)
|
||||
@@ -73,10 +76,12 @@ public static class IntegrationEndpoints
|
||||
group.MapPost("/", async (
|
||||
[FromServices] IntegrationService service,
|
||||
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
|
||||
HttpContext httpContext,
|
||||
[FromBody] CreateIntegrationRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.CreateAsync(request, tenantAccessor.TenantId, null, cancellationToken);
|
||||
var actorId = ResolveActorId(httpContext);
|
||||
var result = await service.CreateAsync(request, tenantAccessor.TenantId, actorId, cancellationToken);
|
||||
return Results.Created($"/api/v1/integrations/{result.Id}", result);
|
||||
})
|
||||
.RequireAuthorization(IntegrationPolicies.Write)
|
||||
@@ -87,11 +92,13 @@ public static class IntegrationEndpoints
|
||||
group.MapPut("/{id:guid}", async (
|
||||
[FromServices] IntegrationService service,
|
||||
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
|
||||
HttpContext httpContext,
|
||||
Guid id,
|
||||
[FromBody] UpdateIntegrationRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, tenantAccessor.TenantId, cancellationToken);
|
||||
var actorId = ResolveActorId(httpContext);
|
||||
var result = await service.UpdateAsync(id, request, tenantAccessor.TenantId, actorId, cancellationToken);
|
||||
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||
})
|
||||
.RequireAuthorization(IntegrationPolicies.Write)
|
||||
@@ -102,10 +109,12 @@ public static class IntegrationEndpoints
|
||||
group.MapDelete("/{id:guid}", async (
|
||||
[FromServices] IntegrationService service,
|
||||
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
|
||||
HttpContext httpContext,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.DeleteAsync(id, tenantAccessor.TenantId, cancellationToken);
|
||||
var actorId = ResolveActorId(httpContext);
|
||||
var result = await service.DeleteAsync(id, tenantAccessor.TenantId, actorId, cancellationToken);
|
||||
return result ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.RequireAuthorization(IntegrationPolicies.Write)
|
||||
@@ -116,10 +125,12 @@ public static class IntegrationEndpoints
|
||||
group.MapPost("/{id:guid}/test", async (
|
||||
[FromServices] IntegrationService service,
|
||||
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
|
||||
HttpContext httpContext,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.TestConnectionAsync(id, tenantAccessor.TenantId, cancellationToken);
|
||||
var actorId = ResolveActorId(httpContext);
|
||||
var result = await service.TestConnectionAsync(id, tenantAccessor.TenantId, actorId, cancellationToken);
|
||||
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||
})
|
||||
.RequireAuthorization(IntegrationPolicies.Operate)
|
||||
@@ -129,10 +140,11 @@ public static class IntegrationEndpoints
|
||||
// Health check
|
||||
group.MapGet("/{id:guid}/health", async (
|
||||
[FromServices] IntegrationService service,
|
||||
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.CheckHealthAsync(id, cancellationToken);
|
||||
var result = await service.CheckHealthAsync(id, tenantAccessor.TenantId, cancellationToken);
|
||||
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||
})
|
||||
.RequireAuthorization(IntegrationPolicies.Read)
|
||||
@@ -142,10 +154,11 @@ public static class IntegrationEndpoints
|
||||
// Impact map
|
||||
group.MapGet("/{id:guid}/impact", async (
|
||||
[FromServices] IntegrationService service,
|
||||
[FromServices] IStellaOpsTenantAccessor tenantAccessor,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var result = await service.GetImpactAsync(id, cancellationToken);
|
||||
var result = await service.GetImpactAsync(id, tenantAccessor.TenantId, cancellationToken);
|
||||
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||
})
|
||||
.RequireAuthorization(IntegrationPolicies.Read)
|
||||
@@ -162,4 +175,12 @@ public static class IntegrationEndpoints
|
||||
.WithName("GetSupportedProviders")
|
||||
.WithDescription(_t("integrations.integration.get_providers_description"));
|
||||
}
|
||||
|
||||
private static string? ResolveActorId(HttpContext httpContext)
|
||||
{
|
||||
return httpContext.User.FindFirst(StellaOpsClaimTypes.Subject)?.Value
|
||||
?? httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? httpContext.User.FindFirst("sub")?.Value
|
||||
?? httpContext.User.Identity?.Name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ public sealed class IntegrationService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IntegrationResponse> CreateAsync(CreateIntegrationRequest request, string? userId, string? tenantId, CancellationToken cancellationToken = default)
|
||||
public async Task<IntegrationResponse> CreateAsync(CreateIntegrationRequest request, string? tenantId, string? userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var integration = new Integration
|
||||
@@ -78,9 +78,9 @@ public sealed class IntegrationService
|
||||
return MapToResponse(created);
|
||||
}
|
||||
|
||||
public async Task<IntegrationResponse?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
public async Task<IntegrationResponse?> GetByIdAsync(Guid id, string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var integration = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken);
|
||||
return integration is null ? null : MapToResponse(integration);
|
||||
}
|
||||
|
||||
@@ -110,9 +110,9 @@ public sealed class IntegrationService
|
||||
totalPages);
|
||||
}
|
||||
|
||||
public async Task<IntegrationResponse?> UpdateAsync(Guid id, UpdateIntegrationRequest request, string? userId, CancellationToken cancellationToken = default)
|
||||
public async Task<IntegrationResponse?> UpdateAsync(Guid id, UpdateIntegrationRequest request, string? tenantId, string? userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var integration = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken);
|
||||
if (integration is null) return null;
|
||||
|
||||
var oldStatus = integration.Status;
|
||||
@@ -153,9 +153,9 @@ public sealed class IntegrationService
|
||||
return MapToResponse(updated);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, string? userId, CancellationToken cancellationToken = default)
|
||||
public async Task<bool> DeleteAsync(Guid id, string? tenantId, string? userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var integration = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken);
|
||||
if (integration is null) return false;
|
||||
|
||||
await _repository.DeleteAsync(id, cancellationToken);
|
||||
@@ -172,9 +172,9 @@ public sealed class IntegrationService
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<TestConnectionResponse?> TestConnectionAsync(Guid id, string? userId, CancellationToken cancellationToken = default)
|
||||
public async Task<TestConnectionResponse?> TestConnectionAsync(Guid id, string? tenantId, string? userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var integration = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken);
|
||||
if (integration is null) return null;
|
||||
|
||||
var plugin = _pluginLoader.GetByProvider(integration.Provider);
|
||||
@@ -227,9 +227,9 @@ public sealed class IntegrationService
|
||||
endTime);
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResponse?> CheckHealthAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
public async Task<HealthCheckResponse?> CheckHealthAsync(Guid id, string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var integration = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken);
|
||||
if (integration is null) return null;
|
||||
|
||||
var plugin = _pluginLoader.GetByProvider(integration.Provider);
|
||||
@@ -269,9 +269,9 @@ public sealed class IntegrationService
|
||||
result.Duration);
|
||||
}
|
||||
|
||||
public async Task<IntegrationImpactResponse?> GetImpactAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
public async Task<IntegrationImpactResponse?> GetImpactAsync(Guid id, string? tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var integration = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
var integration = await GetScopedIntegrationAsync(id, tenantId, cancellationToken);
|
||||
if (integration is null)
|
||||
{
|
||||
return null;
|
||||
@@ -302,6 +302,27 @@ public sealed class IntegrationService
|
||||
p.Provider)).ToList();
|
||||
}
|
||||
|
||||
private async Task<Integration?> GetScopedIntegrationAsync(Guid id, string? tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var integration = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
if (integration is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.Equals(integration.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Integration {IntegrationId} was requested outside its tenant scope. requestedTenant={RequestedTenant} actualTenant={ActualTenant}",
|
||||
id,
|
||||
tenantId,
|
||||
integration.TenantId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return integration;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ImpactedWorkflow> BuildImpactedWorkflows(Integration integration)
|
||||
{
|
||||
var blockedByStatus = integration.Status is IntegrationStatus.Failed or IntegrationStatus.Disabled or IntegrationStatus.Archived;
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin
|
||||
try
|
||||
{
|
||||
// Call GitHub API to verify authentication
|
||||
var response = await client.GetAsync("/app", cancellationToken);
|
||||
var response = await client.GetAsync("app", cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
@@ -98,7 +98,7 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin
|
||||
try
|
||||
{
|
||||
// Check GitHub API status
|
||||
var response = await client.GetAsync("/rate_limit", cancellationToken);
|
||||
var response = await client.GetAsync("rate_limit", cancellationToken);
|
||||
var duration = _timeProvider.GetUtcNow() - startTime;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
@@ -151,9 +151,7 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin
|
||||
|
||||
private static HttpClient CreateHttpClient(IntegrationConfig config)
|
||||
{
|
||||
var baseUrl = string.IsNullOrEmpty(config.Endpoint) || config.Endpoint == "https://github.com"
|
||||
? "https://api.github.com"
|
||||
: config.Endpoint.TrimEnd('/') + "/api/v3";
|
||||
var baseUrl = ResolveBaseUrl(config.Endpoint);
|
||||
|
||||
var client = new HttpClient
|
||||
{
|
||||
@@ -174,6 +172,22 @@ public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin
|
||||
return client;
|
||||
}
|
||||
|
||||
private static string ResolveBaseUrl(string? endpoint)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || endpoint.Equals("https://github.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "https://api.github.com/";
|
||||
}
|
||||
|
||||
var normalized = endpoint.TrimEnd('/');
|
||||
if (normalized.EndsWith("/api/v3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return normalized + "/";
|
||||
}
|
||||
|
||||
return normalized + "/api/v3/";
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using StellaOps.Integrations.Core;
|
||||
using StellaOps.Integrations.Plugin.GitHubApp;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Integrations.Plugin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Focused transport-level tests for GitHubAppConnectorPlugin route construction.
|
||||
/// </summary>
|
||||
public sealed class GitHubAppConnectorPluginTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTime = new(2026, 3, 14, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_UsesApiV3AppRoute_ForEnterpriseBaseEndpoint()
|
||||
{
|
||||
using var fixture = LoopbackHttpFixture.Start(path => path switch
|
||||
{
|
||||
"/api/v3/app" => HttpResponse.Json("""{"id":424242,"name":"Stella QA GitHub App","slug":"stella-qa-app"}"""),
|
||||
_ => HttpResponse.Text("unexpected-path"),
|
||||
});
|
||||
|
||||
var plugin = new GitHubAppConnectorPlugin(new FixedTimeProvider(FixedTime));
|
||||
|
||||
var result = await plugin.TestConnectionAsync(CreateConfig(fixture.BaseUrl));
|
||||
var requestedPath = await fixture.WaitForPathAsync();
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("/api/v3/app", requestedPath);
|
||||
Assert.Contains("Stella QA GitHub App", result.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_DoesNotDuplicateApiV3_WhenEndpointAlreadyIncludesIt()
|
||||
{
|
||||
using var fixture = LoopbackHttpFixture.Start(path => path switch
|
||||
{
|
||||
"/api/v3/app" => HttpResponse.Json("""{"id":424242,"name":"Stella QA GitHub App","slug":"stella-qa-app"}"""),
|
||||
_ => HttpResponse.Text("unexpected-path"),
|
||||
});
|
||||
|
||||
var plugin = new GitHubAppConnectorPlugin(new FixedTimeProvider(FixedTime));
|
||||
|
||||
var result = await plugin.TestConnectionAsync(CreateConfig($"{fixture.BaseUrl}/api/v3"));
|
||||
var requestedPath = await fixture.WaitForPathAsync();
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("/api/v3/app", requestedPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_UsesApiV3RateLimitRoute_ForEnterpriseBaseEndpoint()
|
||||
{
|
||||
using var fixture = LoopbackHttpFixture.Start(path => path switch
|
||||
{
|
||||
"/api/v3/rate_limit" => HttpResponse.Json("""{"resources":{"core":{"limit":5000,"remaining":4991,"reset":1893456000}}}"""),
|
||||
_ => HttpResponse.Text("unexpected-path"),
|
||||
});
|
||||
|
||||
var plugin = new GitHubAppConnectorPlugin(new FixedTimeProvider(FixedTime));
|
||||
|
||||
var result = await plugin.CheckHealthAsync(CreateConfig(fixture.BaseUrl));
|
||||
var requestedPath = await fixture.WaitForPathAsync();
|
||||
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
Assert.Equal("/api/v3/rate_limit", requestedPath);
|
||||
Assert.Contains("Rate limit", result.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static IntegrationConfig CreateConfig(string endpoint)
|
||||
{
|
||||
return new IntegrationConfig(
|
||||
IntegrationId: Guid.NewGuid(),
|
||||
Type: IntegrationType.Scm,
|
||||
Provider: IntegrationProvider.GitHubApp,
|
||||
Endpoint: endpoint,
|
||||
ResolvedSecret: null,
|
||||
OrganizationId: "stellaops",
|
||||
ExtendedConfig: new Dictionary<string, object>
|
||||
{
|
||||
["appId"] = "424242",
|
||||
["installationId"] = "424243",
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
|
||||
private sealed class LoopbackHttpFixture : IDisposable
|
||||
{
|
||||
private readonly TcpListener _listener;
|
||||
private readonly Task<string> _requestPathTask;
|
||||
|
||||
private LoopbackHttpFixture(Func<string, HttpResponse> responder)
|
||||
{
|
||||
_listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
_listener.Start();
|
||||
BaseUrl = $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}";
|
||||
_requestPathTask = HandleSingleRequestAsync(responder);
|
||||
}
|
||||
|
||||
public string BaseUrl { get; }
|
||||
|
||||
public static LoopbackHttpFixture Start(Func<string, HttpResponse> responder) => new(responder);
|
||||
|
||||
public Task<string> WaitForPathAsync() => _requestPathTask;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_listener.Stop();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> HandleSingleRequestAsync(Func<string, HttpResponse> responder)
|
||||
{
|
||||
using var client = await _listener.AcceptTcpClientAsync();
|
||||
using var stream = client.GetStream();
|
||||
using var reader = new StreamReader(
|
||||
stream,
|
||||
Encoding.ASCII,
|
||||
detectEncodingFromByteOrderMarks: false,
|
||||
bufferSize: 1024,
|
||||
leaveOpen: true);
|
||||
|
||||
var requestLine = await reader.ReadLineAsync();
|
||||
if (string.IsNullOrWhiteSpace(requestLine))
|
||||
{
|
||||
throw new InvalidOperationException("Did not receive an HTTP request line.");
|
||||
}
|
||||
|
||||
var requestParts = requestLine.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (requestParts.Length < 2)
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected HTTP request line: {requestLine}");
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
var headerLine = await reader.ReadLineAsync();
|
||||
if (string.IsNullOrEmpty(headerLine))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var requestPath = requestParts[1];
|
||||
var response = responder(requestPath);
|
||||
var payload = Encoding.UTF8.GetBytes(response.Body);
|
||||
var responseText =
|
||||
$"HTTP/1.1 {response.StatusCode} {response.ReasonPhrase}\r\n" +
|
||||
$"Content-Type: {response.ContentType}\r\n" +
|
||||
$"Content-Length: {payload.Length}\r\n" +
|
||||
"Connection: close\r\n" +
|
||||
"\r\n";
|
||||
|
||||
var headerBytes = Encoding.ASCII.GetBytes(responseText);
|
||||
await stream.WriteAsync(headerBytes);
|
||||
await stream.WriteAsync(payload);
|
||||
await stream.FlushAsync();
|
||||
return requestPath;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record HttpResponse(int StatusCode, string ReasonPhrase, string ContentType, string Body)
|
||||
{
|
||||
public static HttpResponse Json(string body) => new(200, "OK", "application/json", body);
|
||||
|
||||
public static HttpResponse Text(string body) => new(200, "OK", "text/plain", body);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.InMemory\StellaOps.Integrations.Plugin.InMemory.csproj" />
|
||||
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.GitHubApp\StellaOps.Integrations.Plugin.GitHubApp.csproj" />
|
||||
<ProjectReference Include="..\..\__Plugins\StellaOps.Integrations.Plugin.Harbor\StellaOps.Integrations.Plugin.Harbor.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Persistence\StellaOps.Integrations.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -30,6 +30,42 @@ public sealed class IntegrationImpactEndpointsTests : IClassFixture<IntegrationI
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task CreateThenListEndpoint_ReturnsCreatedItemForCurrentTenant()
|
||||
{
|
||||
var createRequest = new CreateIntegrationRequest(
|
||||
Name: $"Tenant Harbor {Guid.NewGuid():N}",
|
||||
Description: "Registry integration",
|
||||
Type: IntegrationType.Registry,
|
||||
Provider: IntegrationProvider.InMemory,
|
||||
Endpoint: "http://inmemory.local",
|
||||
AuthRefUri: "authref://vault/inmemory#token",
|
||||
OrganizationId: "tenant-scope",
|
||||
ExtendedConfig: new Dictionary<string, object> { ["source"] = "integration-test" },
|
||||
Tags: ["qa", "tenant"]);
|
||||
|
||||
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);
|
||||
Assert.Equal("test-user", created!.CreatedBy);
|
||||
|
||||
var list = await _client.GetFromJsonAsync<PagedIntegrationsResponse>(
|
||||
$"/api/v1/integrations/?type={(int)IntegrationType.Registry}",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(list);
|
||||
Assert.Equal(1, list!.TotalCount);
|
||||
var item = Assert.Single(list.Items);
|
||||
Assert.Equal(created.Id, item.Id);
|
||||
Assert.Equal("test-user", item.CreatedBy);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ImpactEndpoint_ReturnsDeterministicWorkflowMap()
|
||||
@@ -220,7 +256,41 @@ internal sealed class InMemoryIntegrationRepository : IIntegrationRepository
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return Task.FromResult(_items.Values.Count(item => query.IncludeDeleted || !item.IsDeleted));
|
||||
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));
|
||||
}
|
||||
|
||||
return Task.FromResult(values.Count());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ using Xunit;
|
||||
|
||||
namespace StellaOps.Integrations.Tests;
|
||||
|
||||
public class IntegrationServiceTests
|
||||
public sealed class IntegrationServiceTests
|
||||
{
|
||||
private readonly Mock<IIntegrationRepository> _repositoryMock;
|
||||
private readonly Mock<IIntegrationEventPublisher> _eventPublisherMock;
|
||||
@@ -38,9 +38,8 @@ public class IntegrationServiceTests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithValidRequest_CreatesIntegration()
|
||||
public async Task CreateAsync_WithValidRequest_PersistsTenantAndActor()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateIntegrationRequest(
|
||||
Name: "Test Registry",
|
||||
Description: "Test description",
|
||||
@@ -48,55 +47,43 @@ public class IntegrationServiceTests
|
||||
Provider: IntegrationProvider.Harbor,
|
||||
Endpoint: "https://harbor.example.com",
|
||||
AuthRefUri: "authref://vault/harbor#credentials",
|
||||
OrganizationId: "myorg",
|
||||
OrganizationId: "platform",
|
||||
ExtendedConfig: null,
|
||||
Tags: ["test", "dev"]);
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.CreateAsync(It.IsAny<Integration>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<Integration, CancellationToken>((i, _) => Task.FromResult(i));
|
||||
.Returns<Integration, CancellationToken>((integration, _) => Task.FromResult(integration));
|
||||
|
||||
// Act
|
||||
var result = await _service.CreateAsync(request, "test-user", "tenant-1");
|
||||
var result = await _service.CreateAsync(request, "tenant-1", "test-user");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Name.Should().Be("Test Registry");
|
||||
result.Type.Should().Be(IntegrationType.Registry);
|
||||
result.Provider.Should().Be(IntegrationProvider.Harbor);
|
||||
result.Status.Should().Be(IntegrationStatus.Pending);
|
||||
result.Endpoint.Should().Be("https://harbor.example.com");
|
||||
result.CreatedBy.Should().Be("test-user");
|
||||
result.UpdatedBy.Should().Be("test-user");
|
||||
|
||||
_repositoryMock.Verify(r => r.CreateAsync(
|
||||
It.IsAny<Integration>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
_eventPublisherMock.Verify(e => e.PublishAsync(
|
||||
It.IsAny<IntegrationCreatedEvent>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
_auditLoggerMock.Verify(a => a.LogAsync(
|
||||
"integration.created",
|
||||
It.IsAny<Guid>(),
|
||||
"test-user",
|
||||
It.IsAny<object>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
_repositoryMock.Verify(
|
||||
r => r.CreateAsync(
|
||||
It.Is<Integration>(integration =>
|
||||
integration.TenantId == "tenant-1" &&
|
||||
integration.CreatedBy == "test-user" &&
|
||||
integration.UpdatedBy == "test-user"),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WithExistingId_ReturnsIntegration()
|
||||
public async Task GetByIdAsync_WithMatchingTenant_ReturnsIntegration()
|
||||
{
|
||||
// Arrange
|
||||
var integration = CreateTestIntegration();
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetByIdAsync(integration.Id);
|
||||
var result = await _service.GetByIdAsync(integration.Id, "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Id.Should().Be(integration.Id);
|
||||
result.Name.Should().Be(integration.Name);
|
||||
@@ -104,61 +91,56 @@ public class IntegrationServiceTests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_WithNonExistingId_ReturnsNull()
|
||||
public async Task GetByIdAsync_WithTenantMismatch_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var id = Guid.NewGuid();
|
||||
var integration = CreateTestIntegration(tenantId: "tenant-a");
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Integration?)null);
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetByIdAsync(id);
|
||||
var result = await _service.GetByIdAsync(integration.Id, "tenant-b");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task ListAsync_WithFilters_ReturnsFilteredResults()
|
||||
public async Task ListAsync_WithFilters_ScopesRepositoryQueryToTenant()
|
||||
{
|
||||
// Arrange
|
||||
var integrations = new[]
|
||||
{
|
||||
CreateTestIntegration(type: IntegrationType.Registry),
|
||||
CreateTestIntegration(type: IntegrationType.Registry),
|
||||
CreateTestIntegration(type: IntegrationType.Scm)
|
||||
CreateTestIntegration(type: IntegrationType.Scm),
|
||||
};
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetAllAsync(
|
||||
It.Is<IntegrationQuery>(q => q.Type == IntegrationType.Registry),
|
||||
It.Is<IntegrationQuery>(query =>
|
||||
query.Type == IntegrationType.Registry &&
|
||||
query.TenantId == "tenant-1"),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integrations.Where(i => i.Type == IntegrationType.Registry).ToList());
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.CountAsync(
|
||||
It.Is<IntegrationQuery>(q => q.Type == IntegrationType.Registry),
|
||||
It.Is<IntegrationQuery>(query =>
|
||||
query.Type == IntegrationType.Registry &&
|
||||
query.TenantId == "tenant-1"),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(2);
|
||||
|
||||
var query = new ListIntegrationsQuery(Type: IntegrationType.Registry);
|
||||
var result = await _service.ListAsync(new ListIntegrationsQuery(Type: IntegrationType.Registry), "tenant-1");
|
||||
|
||||
// Act
|
||||
var result = await _service.ListAsync(query, "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Items.Should().HaveCount(2);
|
||||
result.Items.Should().OnlyContain(i => i.Type == IntegrationType.Registry);
|
||||
result.Items.Should().OnlyContain(item => item.Type == IntegrationType.Registry);
|
||||
result.TotalCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_WithExistingIntegration_UpdatesAndPublishesEvent()
|
||||
public async Task UpdateAsync_WithMatchingTenant_UpdatesAndPublishesEvent()
|
||||
{
|
||||
// Arrange
|
||||
var integration = CreateTestIntegration();
|
||||
var request = new UpdateIntegrationRequest(
|
||||
Name: "Updated Name",
|
||||
@@ -175,49 +157,49 @@ public class IntegrationServiceTests
|
||||
.ReturnsAsync(integration);
|
||||
_repositoryMock
|
||||
.Setup(r => r.UpdateAsync(It.IsAny<Integration>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<Integration, CancellationToken>((i, _) => Task.FromResult(i));
|
||||
.Returns<Integration, CancellationToken>((updated, _) => Task.FromResult(updated));
|
||||
|
||||
// Act
|
||||
var result = await _service.UpdateAsync(integration.Id, request, "test-user");
|
||||
var result = await _service.UpdateAsync(integration.Id, request, "tenant-1", "test-user");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Name.Should().Be("Updated Name");
|
||||
result.Description.Should().Be("Updated description");
|
||||
result.Endpoint.Should().Be("https://updated.example.com");
|
||||
|
||||
_eventPublisherMock.Verify(e => e.PublishAsync(
|
||||
It.IsAny<IntegrationUpdatedEvent>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
_eventPublisherMock.Verify(
|
||||
publisher => publisher.PublishAsync(It.IsAny<IntegrationUpdatedEvent>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task UpdateAsync_WithNonExistingIntegration_ReturnsNull()
|
||||
public async Task UpdateAsync_WithTenantMismatch_ReturnsNullWithoutMutation()
|
||||
{
|
||||
// Arrange
|
||||
var id = Guid.NewGuid();
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Integration?)null);
|
||||
|
||||
var integration = CreateTestIntegration(tenantId: "tenant-a");
|
||||
var request = new UpdateIntegrationRequest(
|
||||
Name: "Updated", Description: null, Endpoint: null,
|
||||
AuthRefUri: null, OrganizationId: null, ExtendedConfig: null,
|
||||
Tags: null, Status: null);
|
||||
Name: "Updated",
|
||||
Description: null,
|
||||
Endpoint: null,
|
||||
AuthRefUri: null,
|
||||
OrganizationId: null,
|
||||
ExtendedConfig: null,
|
||||
Tags: null,
|
||||
Status: null);
|
||||
|
||||
// Act
|
||||
var result = await _service.UpdateAsync(id, request, "test-user");
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
var result = await _service.UpdateAsync(integration.Id, request, "tenant-b", "test-user");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
_repositoryMock.Verify(r => r.UpdateAsync(It.IsAny<Integration>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DeleteAsync_WithExistingIntegration_DeletesAndPublishesEvent()
|
||||
public async Task DeleteAsync_WithMatchingTenant_DeletesAndPublishesEvent()
|
||||
{
|
||||
// Arrange
|
||||
var integration = CreateTestIntegration();
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
@@ -226,51 +208,38 @@ public class IntegrationServiceTests
|
||||
.Setup(r => r.DeleteAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _service.DeleteAsync(integration.Id, "test-user");
|
||||
var result = await _service.DeleteAsync(integration.Id, "tenant-1", "test-user");
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
_repositoryMock.Verify(r => r.DeleteAsync(integration.Id, It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
_eventPublisherMock.Verify(e => e.PublishAsync(
|
||||
It.IsAny<IntegrationDeletedEvent>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task DeleteAsync_WithNonExistingIntegration_ReturnsFalse()
|
||||
public async Task DeleteAsync_WithTenantMismatch_ReturnsFalseWithoutDelete()
|
||||
{
|
||||
// Arrange
|
||||
var id = Guid.NewGuid();
|
||||
var integration = CreateTestIntegration(tenantId: "tenant-a");
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Integration?)null);
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
// Act
|
||||
var result = await _service.DeleteAsync(id, "test-user");
|
||||
var result = await _service.DeleteAsync(integration.Id, "tenant-b", "test-user");
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
_repositoryMock.Verify(r => r.DeleteAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_WithNoPlugin_ReturnsFailureResult()
|
||||
public async Task TestConnectionAsync_WithNoPlugin_ReturnsFailureResultForMatchingTenant()
|
||||
{
|
||||
// Arrange
|
||||
var integration = CreateTestIntegration(provider: IntegrationProvider.Harbor);
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
// No plugins loaded in _pluginLoader
|
||||
var result = await _service.TestConnectionAsync(integration.Id, "tenant-1", "test-user");
|
||||
|
||||
// Act
|
||||
var result = await _service.TestConnectionAsync(integration.Id, "test-user");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Success.Should().BeFalse();
|
||||
result.Message.Should().Contain("No connector plugin");
|
||||
@@ -278,66 +247,65 @@ public class IntegrationServiceTests
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_WithNonExistingIntegration_ReturnsNull()
|
||||
public async Task TestConnectionAsync_WithTenantMismatch_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var id = Guid.NewGuid();
|
||||
var integration = CreateTestIntegration(tenantId: "tenant-a");
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Integration?)null);
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
// Act
|
||||
var result = await _service.TestConnectionAsync(id, "test-user");
|
||||
var result = await _service.TestConnectionAsync(integration.Id, "tenant-b", "test-user");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_WithNoPlugin_ReturnsUnknownStatus()
|
||||
public async Task CheckHealthAsync_WithNoPlugin_ReturnsUnknownStatusForMatchingTenant()
|
||||
{
|
||||
// Arrange
|
||||
var integration = CreateTestIntegration();
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
// No plugins loaded
|
||||
var result = await _service.CheckHealthAsync(integration.Id, "tenant-1");
|
||||
|
||||
// Act
|
||||
var result = await _service.CheckHealthAsync(integration.Id);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Status.Should().Be(HealthStatus.Unknown);
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public void GetSupportedProviders_WithNoPlugins_ReturnsEmpty()
|
||||
public async Task CheckHealthAsync_WithTenantMismatch_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var result = _service.GetSupportedProviders();
|
||||
var integration = CreateTestIntegration(tenantId: "tenant-a");
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
var result = await _service.CheckHealthAsync(integration.Id, "tenant-b");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task GetImpactAsync_WithNonExistingIntegration_ReturnsNull()
|
||||
public void GetSupportedProviders_WithNoPlugins_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var id = Guid.NewGuid();
|
||||
_service.GetSupportedProviders().Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task GetImpactAsync_WithTenantMismatch_ReturnsNull()
|
||||
{
|
||||
var integration = CreateTestIntegration(tenantId: "tenant-a");
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Integration?)null);
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetImpactAsync(id);
|
||||
var result = await _service.GetImpactAsync(integration.Id, "tenant-b");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
@@ -345,7 +313,6 @@ public class IntegrationServiceTests
|
||||
[Fact]
|
||||
public async Task GetImpactAsync_WithFailedFeedMirror_ReturnsBlockingHighSeverity()
|
||||
{
|
||||
// Arrange
|
||||
var integration = CreateTestIntegration(
|
||||
type: IntegrationType.FeedMirror,
|
||||
provider: IntegrationProvider.NvdMirror);
|
||||
@@ -356,10 +323,8 @@ public class IntegrationServiceTests
|
||||
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(integration);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetImpactAsync(integration.Id);
|
||||
var result = await _service.GetImpactAsync(integration.Id, "tenant-1");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result!.Severity.Should().Be("high");
|
||||
result.BlockingWorkflowCount.Should().Be(result.TotalWorkflowCount);
|
||||
@@ -370,7 +335,8 @@ public class IntegrationServiceTests
|
||||
|
||||
private static Integration CreateTestIntegration(
|
||||
IntegrationType type = IntegrationType.Registry,
|
||||
IntegrationProvider provider = IntegrationProvider.Harbor)
|
||||
IntegrationProvider provider = IntegrationProvider.Harbor,
|
||||
string tenantId = "tenant-1")
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new Integration
|
||||
@@ -382,10 +348,12 @@ public class IntegrationServiceTests
|
||||
Status = IntegrationStatus.Active,
|
||||
Endpoint = "https://example.com",
|
||||
Description = "Test description",
|
||||
TenantId = tenantId,
|
||||
Tags = ["test"],
|
||||
CreatedBy = "test-user",
|
||||
UpdatedBy = "test-user",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
UpdatedAt = now,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,16 @@ const suites = [
|
||||
script: 'live-integrations-action-sweep.mjs',
|
||||
reportPath: path.join(outputDir, 'live-integrations-action-sweep.json'),
|
||||
},
|
||||
{
|
||||
name: 'integrations-onboarding-persistence-check',
|
||||
script: 'live-integrations-onboarding-persistence-check.mjs',
|
||||
reportPath: path.join(outputDir, 'live-integrations-onboarding-persistence-check.json'),
|
||||
},
|
||||
{
|
||||
name: 'integrations-onboarding-success-fixtures-check',
|
||||
script: 'live-integrations-onboarding-success-fixtures-check.mjs',
|
||||
reportPath: path.join(outputDir, 'live-integrations-onboarding-success-fixtures-check.json'),
|
||||
},
|
||||
{
|
||||
name: 'setup-topology-action-sweep',
|
||||
script: 'live-setup-topology-action-sweep.mjs',
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
|
||||
|
||||
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const outputDir = path.join(webRoot, 'output', 'playwright');
|
||||
const outputPath = path.join(outputDir, 'live-integrations-onboarding-persistence-check.json');
|
||||
const authStatePath = path.join(outputDir, 'live-integrations-onboarding-persistence-check.state.json');
|
||||
const authReportPath = path.join(outputDir, 'live-integrations-onboarding-persistence-check.auth.json');
|
||||
const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d';
|
||||
|
||||
function scopedUrl(route) {
|
||||
const separator = route.includes('?') ? '&' : '?';
|
||||
return `https://stella-ops.local${route}${separator}${scopeQuery}`;
|
||||
}
|
||||
|
||||
async function settle(page, waitMs = 1_500) {
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
|
||||
await page.waitForTimeout(waitMs);
|
||||
}
|
||||
|
||||
async function headingText(page) {
|
||||
const headings = page.locator('h1, h2');
|
||||
const count = await headings.count();
|
||||
for (let index = 0; index < Math.min(count, 4); index += 1) {
|
||||
const text = (await headings.nth(index).innerText().catch(() => '')).trim();
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function captureSnapshot(page, label) {
|
||||
const alerts = await page
|
||||
.locator('[role="alert"], .check-item.status-error, .error-state, .result-card, .detail-state')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => (node.textContent || '').trim().replace(/\s+/g, ' '))
|
||||
.filter(Boolean)
|
||||
.slice(0, 6),
|
||||
)
|
||||
.catch(() => []);
|
||||
|
||||
return {
|
||||
label,
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
heading: await headingText(page),
|
||||
alerts,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistSummary(summary) {
|
||||
summary.lastUpdatedAtUtc = new Date().toISOString();
|
||||
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function recordCheck(summary, label, runner) {
|
||||
const startedAtUtc = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const result = await runner();
|
||||
summary.checks.push({
|
||||
label,
|
||||
ok: result?.ok ?? true,
|
||||
startedAtUtc,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
summary.checks.push({
|
||||
label,
|
||||
ok: false,
|
||||
startedAtUtc,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
await persistSummary(summary);
|
||||
}
|
||||
|
||||
async function waitForEnabled(locator, timeoutMs = 15_000) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if ((await locator.count().catch(() => 0)) > 0 && await locator.isEnabled().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await locator.page().waitForTimeout(250);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for control to enable after ${timeoutMs}ms.`);
|
||||
}
|
||||
|
||||
async function openRegistryWizard(page) {
|
||||
const authHeading = page.getByRole('heading', { name: /Connection & Credentials/i });
|
||||
const harborButton = page.getByRole('button', { name: /Harbor/i });
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < 15_000) {
|
||||
if (await authHeading.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await harborButton.isVisible().catch(() => false)) {
|
||||
await harborButton.click({ timeout: 10_000 });
|
||||
await authHeading.waitFor({ timeout: 15_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for the registry onboarding wizard to reach provider or auth state.');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const authReport = await authenticateFrontdoor({
|
||||
statePath: authStatePath,
|
||||
reportPath: authReportPath,
|
||||
headless: true,
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
const context = await createAuthenticatedContext(browser, authReport, { statePath: authStatePath });
|
||||
const page = await context.newPage();
|
||||
|
||||
const runtime = {
|
||||
consoleErrors: [],
|
||||
pageErrors: [],
|
||||
responseErrors: [],
|
||||
requestFailures: [],
|
||||
};
|
||||
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
runtime.consoleErrors.push({ page: page.url(), text: message.text() });
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => {
|
||||
runtime.pageErrors.push({ page: page.url(), message: error.message });
|
||||
});
|
||||
page.on('requestfailed', (request) => {
|
||||
const url = request.url();
|
||||
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorText = request.failure()?.errorText ?? 'unknown';
|
||||
if (errorText === 'net::ERR_ABORTED') {
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.requestFailures.push({
|
||||
page: page.url(),
|
||||
method: request.method(),
|
||||
url,
|
||||
error: errorText,
|
||||
});
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status() >= 400) {
|
||||
runtime.responseErrors.push({
|
||||
page: page.url(),
|
||||
method: response.request().method(),
|
||||
status: response.status(),
|
||||
url,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const summary = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
runtime,
|
||||
checks: [],
|
||||
cleanup: null,
|
||||
};
|
||||
|
||||
let createdIntegrationId = null;
|
||||
|
||||
try {
|
||||
await page.goto(scopedUrl('/ops/integrations/onboarding'), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
await settle(page);
|
||||
|
||||
await recordCheck(summary, 'onboarding-hub-ui-contract', async () => {
|
||||
const registryPills = await page.locator('.category-section').nth(0).locator('.provider-pill').allInnerTexts();
|
||||
const scmPills = await page.locator('.category-section').nth(1).locator('.provider-pill').allInnerTexts();
|
||||
const ciButtonDisabled = await page.getByRole('button', { name: /Add CI\/CD/i }).isDisabled();
|
||||
const hostButtonDisabled = await page.getByRole('button', { name: /Add Host/i }).isDisabled();
|
||||
|
||||
return {
|
||||
ok: registryPills.join(',') === 'Harbor'
|
||||
&& scmPills.join(',') === 'GitHub App'
|
||||
&& ciButtonDisabled
|
||||
&& hostButtonDisabled,
|
||||
registryPills,
|
||||
scmPills,
|
||||
ciButtonDisabled,
|
||||
hostButtonDisabled,
|
||||
snapshot: await captureSnapshot(page, 'onboarding-hub-ui-contract'),
|
||||
};
|
||||
});
|
||||
|
||||
const addRegistryButton = page.getByRole('button', { name: /Add Registry/i });
|
||||
await addRegistryButton.click({ timeout: 10_000 });
|
||||
await page.waitForURL((url) => url.pathname.includes('/ops/integrations/onboarding/registry'), { timeout: 15_000 });
|
||||
await openRegistryWizard(page);
|
||||
await settle(page);
|
||||
|
||||
await recordCheck(summary, 'typed-onboarding-route', async () => ({
|
||||
ok: page.url().includes('/ops/integrations/onboarding/registry')
|
||||
&& page.url().includes('tenant=demo-prod')
|
||||
&& page.url().includes('regions=us-east')
|
||||
&& page.url().includes('environments=stage')
|
||||
&& page.url().includes('timeWindow=7d'),
|
||||
snapshot: await captureSnapshot(page, 'typed-onboarding-route'),
|
||||
}));
|
||||
|
||||
const uniqueSuffix = Date.now().toString();
|
||||
const integrationName = `QA Harbor ${uniqueSuffix}`;
|
||||
|
||||
await page.getByLabel(/Endpoint/i).fill('https://harbor-ui-qa.invalid');
|
||||
await page.getByLabel(/AuthRef URI/i).fill(`authref://vault/harbor#robot-${uniqueSuffix}`);
|
||||
await page.getByLabel(/Project \/ Namespace/i).fill('qa-platform');
|
||||
await page.getByRole('button', { name: /^Next$/i }).click();
|
||||
await page.getByRole('heading', { name: /Discovery Scope/i }).waitFor({ timeout: 15_000 });
|
||||
|
||||
await page.getByLabel(/Namespaces \/ Projects/i).fill('qa-platform');
|
||||
await page.getByLabel(/Tag Patterns/i).fill('release-*');
|
||||
await page.getByRole('button', { name: /^Next$/i }).click();
|
||||
await page.getByRole('heading', { name: /Check Schedule/i }).waitFor({ timeout: 15_000 });
|
||||
|
||||
await page.getByRole('button', { name: /^Next$/i }).click();
|
||||
await page.getByRole('heading', { name: /Preflight Checks/i }).waitFor({ timeout: 15_000 });
|
||||
|
||||
const nextButton = page.getByRole('button', { name: /^Next$/i });
|
||||
await waitForEnabled(nextButton);
|
||||
await nextButton.click();
|
||||
await page.getByRole('heading', { name: /Review & Create/i }).waitFor({ timeout: 15_000 });
|
||||
|
||||
await page.getByLabel(/Integration Name/i).fill(integrationName);
|
||||
await page.getByRole('button', { name: /Create Integration/i }).click();
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.startsWith('/ops/integrations/') && !url.pathname.includes('/onboarding/'),
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
await settle(page);
|
||||
|
||||
createdIntegrationId = new URL(page.url()).pathname.split('/').filter(Boolean).pop() ?? null;
|
||||
|
||||
await recordCheck(summary, 'ui-create-and-detail-render', async () => {
|
||||
const pageText = await page.locator('body').innerText();
|
||||
|
||||
return {
|
||||
ok: pageText.includes(integrationName)
|
||||
&& pageText.includes('https://harbor-ui-qa.invalid')
|
||||
&& pageText.includes('Configured via AuthRef'),
|
||||
createdIntegrationId,
|
||||
snapshot: await captureSnapshot(page, 'ui-create-and-detail-render'),
|
||||
};
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /^Health$/i }).click();
|
||||
await settle(page, 750);
|
||||
await page.getByRole('button', { name: /Test Connection/i }).click();
|
||||
await page.getByRole('heading', { name: /Last Test Result/i }).waitFor({ timeout: 15_000 });
|
||||
await settle(page);
|
||||
|
||||
await recordCheck(summary, 'detail-test-connection-action', async () => {
|
||||
const resultCardText = await page.locator('.result-card').first().innerText();
|
||||
return {
|
||||
ok: /Success|Failed/i.test(resultCardText) && resultCardText.trim().length > 0,
|
||||
resultCardText,
|
||||
snapshot: await captureSnapshot(page, 'detail-test-connection-action'),
|
||||
};
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /Check Health/i }).click();
|
||||
await page.getByRole('heading', { name: /Last Health Check/i }).waitFor({ timeout: 15_000 });
|
||||
await settle(page);
|
||||
|
||||
await recordCheck(summary, 'detail-health-action', async () => {
|
||||
const resultCards = await page.locator('.result-card').allInnerTexts();
|
||||
const combined = resultCards.join(' | ');
|
||||
return {
|
||||
ok: /Healthy|Degraded|Unhealthy|Unknown/i.test(combined) && combined.trim().length > 0,
|
||||
resultCards,
|
||||
snapshot: await captureSnapshot(page, 'detail-health-action'),
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
if (createdIntegrationId) {
|
||||
try {
|
||||
if (!page.url().includes(`/ops/integrations/${createdIntegrationId}`)) {
|
||||
await page.goto(scopedUrl(`/ops/integrations/${createdIntegrationId}`), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: /^Credentials$/i }).click({ timeout: 10_000 });
|
||||
await settle(page, 750);
|
||||
page.once('dialog', (dialog) => dialog.accept().catch(() => {}));
|
||||
await page.getByRole('button', { name: /Delete Integration/i }).click({ timeout: 10_000 });
|
||||
await page.waitForURL((url) => url.pathname === '/ops/integrations', { timeout: 15_000 });
|
||||
summary.cleanup = {
|
||||
createdIntegrationId,
|
||||
ok: true,
|
||||
finalUrl: page.url(),
|
||||
};
|
||||
} catch (error) {
|
||||
summary.cleanup = {
|
||||
createdIntegrationId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
summary.failedCheckCount = summary.checks.filter((check) => check.ok === false).length;
|
||||
summary.runtimeIssueCount =
|
||||
runtime.consoleErrors.length
|
||||
+ runtime.pageErrors.length
|
||||
+ runtime.responseErrors.length
|
||||
+ runtime.requestFailures.length;
|
||||
|
||||
await persistSummary(summary).catch(() => {});
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
||||
if (summary.failedCheckCount > 0 || summary.runtimeIssueCount > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`[live-integrations-onboarding-persistence-check] ${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,491 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
|
||||
|
||||
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const outputDir = path.join(webRoot, 'output', 'playwright');
|
||||
const outputPath = path.join(outputDir, 'live-integrations-onboarding-success-fixtures-check.json');
|
||||
const authStatePath = path.join(outputDir, 'live-integrations-onboarding-success-fixtures-check.state.json');
|
||||
const authReportPath = path.join(outputDir, 'live-integrations-onboarding-success-fixtures-check.auth.json');
|
||||
const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d';
|
||||
|
||||
function scopedUrl(route) {
|
||||
const separator = route.includes('?') ? '&' : '?';
|
||||
return `https://stella-ops.local${route}${separator}${scopeQuery}`;
|
||||
}
|
||||
|
||||
async function settle(page, waitMs = 1_000) {
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
|
||||
await page.waitForTimeout(waitMs);
|
||||
}
|
||||
|
||||
async function headingText(page) {
|
||||
const headings = page.locator('h1, h2');
|
||||
const count = await headings.count();
|
||||
for (let index = 0; index < Math.min(count, 4); index += 1) {
|
||||
const text = (await headings.nth(index).innerText().catch(() => '')).trim();
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function captureSnapshot(page, label) {
|
||||
const alerts = await page
|
||||
.locator('[role="alert"], .check-item.status-error, .error-state, .result-card, .detail-state')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => (node.textContent || '').trim().replace(/\s+/g, ' '))
|
||||
.filter(Boolean)
|
||||
.slice(0, 8),
|
||||
)
|
||||
.catch(() => []);
|
||||
|
||||
return {
|
||||
label,
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
heading: await headingText(page),
|
||||
alerts,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistSummary(summary) {
|
||||
summary.lastUpdatedAtUtc = new Date().toISOString();
|
||||
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function recordCheck(summary, label, runner) {
|
||||
const startedAtUtc = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const result = await runner();
|
||||
summary.checks.push({
|
||||
label,
|
||||
ok: result?.ok ?? true,
|
||||
startedAtUtc,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
summary.checks.push({
|
||||
label,
|
||||
ok: false,
|
||||
startedAtUtc,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
await persistSummary(summary);
|
||||
}
|
||||
|
||||
async function waitForEnabled(locator, timeoutMs = 15_000) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if ((await locator.count().catch(() => 0)) > 0 && await locator.isEnabled().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await locator.page().waitForTimeout(250);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for control to enable after ${timeoutMs}ms.`);
|
||||
}
|
||||
|
||||
async function openSetupOnboarding(page) {
|
||||
await page.goto(scopedUrl('/setup/integrations'), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
await settle(page);
|
||||
|
||||
await page.getByRole('button', { name: /\+ Add Integration/i }).click({ timeout: 10_000 });
|
||||
await page.waitForURL((url) => url.pathname === '/setup/integrations/onboarding', { timeout: 15_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function openTypedWizard(page, triggerName) {
|
||||
const authHeading = page.getByRole('heading', { name: /Connection & Credentials/i });
|
||||
const triggerButton = page.getByRole('button', { name: triggerName });
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < 15_000) {
|
||||
if (await authHeading.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await triggerButton.isVisible().catch(() => false)) {
|
||||
await triggerButton.click({ timeout: 10_000 });
|
||||
await authHeading.waitFor({ timeout: 15_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${triggerName} onboarding wizard.`);
|
||||
}
|
||||
|
||||
async function advanceWizardToReview(page, { scopeHeading, fillScope }) {
|
||||
await page.getByRole('button', { name: /^Next$/i }).click({ timeout: 10_000 });
|
||||
await page.getByRole('heading', { name: scopeHeading }).waitFor({ timeout: 15_000 });
|
||||
await fillScope();
|
||||
|
||||
await page.getByRole('button', { name: /^Next$/i }).click({ timeout: 10_000 });
|
||||
await page.getByRole('heading', { name: /Check Schedule/i }).waitFor({ timeout: 15_000 });
|
||||
|
||||
await page.getByRole('button', { name: /^Next$/i }).click({ timeout: 10_000 });
|
||||
await page.getByRole('heading', { name: /Preflight Checks/i }).waitFor({ timeout: 15_000 });
|
||||
|
||||
const nextButton = page.getByRole('button', { name: /^Next$/i });
|
||||
await waitForEnabled(nextButton);
|
||||
await nextButton.click({ timeout: 10_000 });
|
||||
await page.getByRole('heading', { name: /Review & Create/i }).waitFor({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function waitForDetailRoute(page) {
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.startsWith('/setup/integrations/') && !url.pathname.includes('/onboarding/'),
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function testDetailActions(page, providerName, expectedSuccessPattern, expectedHealthPattern) {
|
||||
await page.getByRole('button', { name: /^Health$/i }).click({ timeout: 10_000 });
|
||||
await settle(page, 500);
|
||||
|
||||
await page.getByRole('button', { name: /Test Connection/i }).click({ timeout: 10_000 });
|
||||
await page.getByRole('heading', { name: /Last Test Result/i }).waitFor({ timeout: 15_000 });
|
||||
await settle(page);
|
||||
|
||||
const testCardText = await page.locator('.result-card').first().innerText();
|
||||
if (!expectedSuccessPattern.test(testCardText)) {
|
||||
throw new Error(`${providerName} test connection did not match expected success pattern. Saw: ${testCardText}`);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: /Check Health/i }).click({ timeout: 10_000 });
|
||||
await page.getByRole('heading', { name: /Last Health Check/i }).waitFor({ timeout: 15_000 });
|
||||
await settle(page);
|
||||
|
||||
const resultCards = await page.locator('.result-card').allInnerTexts();
|
||||
const combined = resultCards.join(' | ');
|
||||
if (!expectedHealthPattern.test(combined)) {
|
||||
throw new Error(`${providerName} health check did not match expected health pattern. Saw: ${combined}`);
|
||||
}
|
||||
|
||||
return {
|
||||
testCardText,
|
||||
resultCards,
|
||||
combined,
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteIntegration(page, detailPath) {
|
||||
if (!page.url().includes(detailPath)) {
|
||||
await page.goto(scopedUrl(detailPath), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: /^Credentials$/i }).click({ timeout: 10_000 });
|
||||
await settle(page, 500);
|
||||
page.once('dialog', (dialog) => dialog.accept().catch(() => {}));
|
||||
await page.getByRole('button', { name: /Delete Integration/i }).click({ timeout: 10_000 });
|
||||
await page.waitForURL((url) => url.pathname === '/setup/integrations', { timeout: 15_000 });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function createHarborIntegration(page, summary, uniqueSuffix) {
|
||||
await openSetupOnboarding(page);
|
||||
await openTypedWizard(page, /\+ Add Registry/i);
|
||||
await settle(page);
|
||||
|
||||
const integrationName = `QA Harbor Fixture ${uniqueSuffix}`;
|
||||
await page.getByLabel(/Endpoint/i).fill('http://harbor-fixture.stella-ops.local');
|
||||
await page.getByLabel(/AuthRef URI/i).fill(`authref://vault/harbor#robot-${uniqueSuffix}`);
|
||||
await page.getByLabel(/Project \/ Namespace/i).fill('qa-platform');
|
||||
|
||||
await advanceWizardToReview(page, {
|
||||
scopeHeading: /Discovery Scope/i,
|
||||
fillScope: async () => {
|
||||
await page.getByLabel(/Namespaces \/ Projects/i).fill('qa-platform');
|
||||
await page.getByLabel(/Tag Patterns/i).fill('release-*');
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByLabel(/Integration Name/i).fill(integrationName);
|
||||
await page.getByRole('button', { name: /Create Integration/i }).click({ timeout: 10_000 });
|
||||
await waitForDetailRoute(page);
|
||||
|
||||
const createdIntegrationId = new URL(page.url()).pathname.split('/').filter(Boolean).pop() ?? null;
|
||||
const pageText = await page.locator('body').innerText();
|
||||
|
||||
await recordCheck(summary, 'setup-harbor-create-and-detail-render', async () => ({
|
||||
ok: Boolean(createdIntegrationId)
|
||||
&& page.url().includes('/setup/integrations/')
|
||||
&& pageText.includes(integrationName)
|
||||
&& pageText.includes('http://harbor-fixture.stella-ops.local')
|
||||
&& pageText.includes('Configured via AuthRef'),
|
||||
createdIntegrationId,
|
||||
integrationName,
|
||||
snapshot: await captureSnapshot(page, 'setup-harbor-create-and-detail-render'),
|
||||
}));
|
||||
|
||||
const actionResult = await testDetailActions(
|
||||
page,
|
||||
'Harbor',
|
||||
/Harbor connection successful/i,
|
||||
/Healthy|Harbor status: healthy/i,
|
||||
);
|
||||
|
||||
await recordCheck(summary, 'setup-harbor-success-actions', async () => ({
|
||||
ok: true,
|
||||
createdIntegrationId,
|
||||
integrationName,
|
||||
...actionResult,
|
||||
snapshot: await captureSnapshot(page, 'setup-harbor-success-actions'),
|
||||
}));
|
||||
|
||||
return {
|
||||
id: createdIntegrationId,
|
||||
name: integrationName,
|
||||
detailPath: `/setup/integrations/${createdIntegrationId}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function createGitHubIntegration(page, summary, uniqueSuffix) {
|
||||
await openSetupOnboarding(page);
|
||||
await openTypedWizard(page, /\+ Add SCM/i);
|
||||
await settle(page);
|
||||
|
||||
const integrationName = `QA GitHub Fixture ${uniqueSuffix}`;
|
||||
await page.getByLabel(/Endpoint/i).fill('http://github-app-fixture.stella-ops.local');
|
||||
await page.getByLabel(/AuthRef URI/i).fill(`authref://vault/github#app-${uniqueSuffix}`);
|
||||
await page.getByLabel(/Owner \/ Organization/i).fill('stellaops');
|
||||
await page.getByLabel(/GitHub App ID/i).fill('424242');
|
||||
await page.getByLabel(/Installation ID/i).fill('424243');
|
||||
|
||||
await advanceWizardToReview(page, {
|
||||
scopeHeading: /Discovery Scope/i,
|
||||
fillScope: async () => {
|
||||
await page.getByLabel(/Repositories/i).fill('stellaops/demo');
|
||||
await page.getByLabel(/Branch Patterns/i).fill('main');
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByLabel(/Integration Name/i).fill(integrationName);
|
||||
await page.getByRole('button', { name: /Create Integration/i }).click({ timeout: 10_000 });
|
||||
await waitForDetailRoute(page);
|
||||
|
||||
const createdIntegrationId = new URL(page.url()).pathname.split('/').filter(Boolean).pop() ?? null;
|
||||
const pageText = await page.locator('body').innerText();
|
||||
|
||||
await recordCheck(summary, 'setup-github-create-and-detail-render', async () => ({
|
||||
ok: Boolean(createdIntegrationId)
|
||||
&& page.url().includes('/setup/integrations/')
|
||||
&& pageText.includes(integrationName)
|
||||
&& pageText.includes('http://github-app-fixture.stella-ops.local')
|
||||
&& pageText.includes('Configured via AuthRef'),
|
||||
createdIntegrationId,
|
||||
integrationName,
|
||||
snapshot: await captureSnapshot(page, 'setup-github-create-and-detail-render'),
|
||||
}));
|
||||
|
||||
const actionResult = await testDetailActions(
|
||||
page,
|
||||
'GitHub App',
|
||||
/Connected as GitHub App: Stella QA GitHub App/i,
|
||||
/Healthy|Rate limit:/i,
|
||||
);
|
||||
|
||||
await recordCheck(summary, 'setup-github-success-actions', async () => ({
|
||||
ok: true,
|
||||
createdIntegrationId,
|
||||
integrationName,
|
||||
...actionResult,
|
||||
snapshot: await captureSnapshot(page, 'setup-github-success-actions'),
|
||||
}));
|
||||
|
||||
return {
|
||||
id: createdIntegrationId,
|
||||
name: integrationName,
|
||||
detailPath: `/setup/integrations/${createdIntegrationId}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const authReport = await authenticateFrontdoor({
|
||||
statePath: authStatePath,
|
||||
reportPath: authReportPath,
|
||||
headless: true,
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
const context = await createAuthenticatedContext(browser, authReport, { statePath: authStatePath });
|
||||
const page = await context.newPage();
|
||||
|
||||
const runtime = {
|
||||
consoleErrors: [],
|
||||
pageErrors: [],
|
||||
responseErrors: [],
|
||||
requestFailures: [],
|
||||
};
|
||||
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
runtime.consoleErrors.push({ page: page.url(), text: message.text() });
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => {
|
||||
runtime.pageErrors.push({ page: page.url(), message: error.message });
|
||||
});
|
||||
page.on('requestfailed', (request) => {
|
||||
const url = request.url();
|
||||
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorText = request.failure()?.errorText ?? 'unknown';
|
||||
if (errorText === 'net::ERR_ABORTED') {
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.requestFailures.push({
|
||||
page: page.url(),
|
||||
method: request.method(),
|
||||
url,
|
||||
error: errorText,
|
||||
});
|
||||
});
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status() >= 400) {
|
||||
runtime.responseErrors.push({
|
||||
page: page.url(),
|
||||
method: response.request().method(),
|
||||
status: response.status(),
|
||||
url,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const summary = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
runtime,
|
||||
checks: [],
|
||||
cleanup: [],
|
||||
};
|
||||
|
||||
const createdIntegrations = [];
|
||||
|
||||
try {
|
||||
await page.goto(scopedUrl('/setup/integrations'), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
await settle(page);
|
||||
|
||||
await recordCheck(summary, 'setup-integrations-entry', async () => {
|
||||
const pageText = await page.locator('body').innerText();
|
||||
return {
|
||||
ok: page.url().includes('/setup/integrations')
|
||||
&& pageText.includes('Registries')
|
||||
&& pageText.includes('SCM')
|
||||
&& await page.getByRole('button', { name: /\+ Add Integration/i }).isVisible(),
|
||||
snapshot: await captureSnapshot(page, 'setup-integrations-entry'),
|
||||
};
|
||||
});
|
||||
|
||||
const uniqueSuffix = Date.now().toString();
|
||||
|
||||
const harbor = await createHarborIntegration(page, summary, uniqueSuffix);
|
||||
createdIntegrations.push(harbor);
|
||||
await deleteIntegration(page, harbor.detailPath);
|
||||
summary.cleanup.push({
|
||||
provider: 'Harbor',
|
||||
integrationId: harbor.id,
|
||||
ok: true,
|
||||
finalUrl: page.url(),
|
||||
});
|
||||
|
||||
const github = await createGitHubIntegration(page, summary, uniqueSuffix);
|
||||
createdIntegrations.push(github);
|
||||
await deleteIntegration(page, github.detailPath);
|
||||
summary.cleanup.push({
|
||||
provider: 'GitHub App',
|
||||
integrationId: github.id,
|
||||
ok: true,
|
||||
finalUrl: page.url(),
|
||||
});
|
||||
|
||||
await recordCheck(summary, 'setup-integrations-return-after-cleanup', async () => ({
|
||||
ok: page.url().includes('/setup/integrations')
|
||||
&& await page.getByRole('button', { name: /\+ Add Integration/i }).isVisible(),
|
||||
snapshot: await captureSnapshot(page, 'setup-integrations-return-after-cleanup'),
|
||||
}));
|
||||
} finally {
|
||||
for (const integration of createdIntegrations.slice().reverse()) {
|
||||
const alreadyDeleted = summary.cleanup.some((entry) => entry.integrationId === integration.id && entry.ok);
|
||||
if (alreadyDeleted || !integration.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteIntegration(page, integration.detailPath);
|
||||
summary.cleanup.push({
|
||||
provider: integration.name,
|
||||
integrationId: integration.id,
|
||||
ok: true,
|
||||
finalUrl: page.url(),
|
||||
});
|
||||
} catch (error) {
|
||||
summary.cleanup.push({
|
||||
provider: integration.name,
|
||||
integrationId: integration.id,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
summary.failedCheckCount = summary.checks.filter((check) => check.ok === false).length;
|
||||
summary.runtimeIssueCount =
|
||||
runtime.consoleErrors.length
|
||||
+ runtime.pageErrors.length
|
||||
+ runtime.responseErrors.length
|
||||
+ runtime.requestFailures.length;
|
||||
|
||||
await persistSummary(summary).catch(() => {});
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
||||
if (summary.failedCheckCount > 0 || summary.runtimeIssueCount > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`[live-integrations-onboarding-success-fixtures-check] ${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,192 +1,98 @@
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { IntegrationDetailComponent } from './integration-detail.component';
|
||||
import { IntegrationService } from './integration.service';
|
||||
import { Integration, IntegrationType, IntegrationStatus, ConnectionTestResult } from './integration.models';
|
||||
import {
|
||||
HealthStatus,
|
||||
IntegrationProvider,
|
||||
IntegrationStatus,
|
||||
IntegrationType,
|
||||
} from './integration.models';
|
||||
|
||||
describe('IntegrationDetailComponent', () => {
|
||||
let component: IntegrationDetailComponent;
|
||||
let fixture: ComponentFixture<IntegrationDetailComponent>;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
const mockIntegration: Integration = {
|
||||
id: '1',
|
||||
name: 'Harbor Registry',
|
||||
type: IntegrationType.Registry,
|
||||
provider: 'harbor',
|
||||
status: IntegrationStatus.Active,
|
||||
description: 'Main container registry',
|
||||
tags: ['production'],
|
||||
configuration: {
|
||||
endpoint: 'https://harbor.example.com',
|
||||
username: 'admin'
|
||||
},
|
||||
createdAt: '2025-12-29T12:00:00Z',
|
||||
updatedAt: '2025-12-29T12:00:00Z',
|
||||
createdBy: 'admin',
|
||||
lastHealthCheck: '2025-12-29T11:55:00Z',
|
||||
healthStatus: 'healthy'
|
||||
};
|
||||
let component: IntegrationDetailComponent;
|
||||
let integrationService: jasmine.SpyObj<IntegrationService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
integrationService = jasmine.createSpyObj<IntegrationService>('IntegrationService', ['get', 'testConnection', 'getHealth', 'delete']);
|
||||
integrationService.get.and.returnValue(of({
|
||||
id: 'int-1',
|
||||
name: 'Harbor Registry',
|
||||
description: 'Main registry',
|
||||
type: IntegrationType.Registry,
|
||||
provider: IntegrationProvider.Harbor,
|
||||
status: IntegrationStatus.Active,
|
||||
endpoint: 'https://harbor.example.com',
|
||||
hasAuth: true,
|
||||
organizationId: 'platform',
|
||||
lastHealthStatus: HealthStatus.Healthy,
|
||||
lastHealthCheckAt: '2026-03-14T10:00:00Z',
|
||||
createdAt: '2026-03-14T09:00:00Z',
|
||||
updatedAt: '2026-03-14T10:00:00Z',
|
||||
createdBy: 'demo-user',
|
||||
updatedBy: 'demo-user',
|
||||
tags: ['prod'],
|
||||
}));
|
||||
integrationService.testConnection.and.returnValue(of({
|
||||
integrationId: 'int-1',
|
||||
success: false,
|
||||
message: 'Connection failed: ENOTFOUND harbor.example.com',
|
||||
details: { endpoint: 'https://harbor.example.com' },
|
||||
duration: '00:00:00.1000000',
|
||||
testedAt: '2026-03-14T10:05:00Z',
|
||||
}));
|
||||
integrationService.getHealth.and.returnValue(of({
|
||||
integrationId: 'int-1',
|
||||
status: HealthStatus.Unhealthy,
|
||||
message: 'Health check failed: ENOTFOUND harbor.example.com',
|
||||
details: { endpoint: 'https://harbor.example.com' },
|
||||
checkedAt: '2026-03-14T10:06:00Z',
|
||||
duration: '00:00:00.1000000',
|
||||
}));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IntegrationDetailComponent],
|
||||
providers: [
|
||||
IntegrationService,
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting()
|
||||
]
|
||||
provideRouter([]),
|
||||
{ provide: IntegrationService, useValue: integrationService },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ integrationId: 'int-1' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(IntegrationDetailComponent);
|
||||
component = fixture.componentInstance;
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load integration details when integrationId is set', () => {
|
||||
component.integrationId = '1';
|
||||
fixture.detectChanges();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations/1');
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(mockIntegration);
|
||||
|
||||
expect(component.integration).toEqual(mockIntegration);
|
||||
});
|
||||
|
||||
it('should test connection successfully', fakeAsync(() => {
|
||||
component.integration = mockIntegration;
|
||||
fixture.detectChanges();
|
||||
|
||||
const testResult: ConnectionTestResult = {
|
||||
success: true,
|
||||
message: 'Connected successfully',
|
||||
latencyMs: 45
|
||||
};
|
||||
it('loads canonical integration details and uses endpoint and health status fields', () => {
|
||||
expect(component.integration?.id).toBe('int-1');
|
||||
expect(component.integration?.endpoint).toBe('https://harbor.example.com');
|
||||
expect(component.getHealthLabel(component.integration!.lastHealthStatus)).toBe('Healthy');
|
||||
});
|
||||
|
||||
it('captures test connection results using backend message and duration fields', () => {
|
||||
component.testConnection();
|
||||
tick();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations/1/test-connection');
|
||||
expect(req.request.method).toBe('POST');
|
||||
req.flush(testResult);
|
||||
|
||||
tick();
|
||||
|
||||
expect(component.connectionTestResult).toEqual(testResult);
|
||||
expect(component.isTestingConnection).toBeFalse();
|
||||
}));
|
||||
|
||||
it('should handle test connection failure', fakeAsync(() => {
|
||||
component.integration = mockIntegration;
|
||||
fixture.detectChanges();
|
||||
|
||||
const testResult: ConnectionTestResult = {
|
||||
success: false,
|
||||
message: 'Connection refused',
|
||||
error: 'ECONNREFUSED'
|
||||
};
|
||||
|
||||
component.testConnection();
|
||||
tick();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations/1/test-connection');
|
||||
req.flush(testResult);
|
||||
|
||||
tick();
|
||||
|
||||
expect(component.connectionTestResult?.success).toBeFalse();
|
||||
expect(component.connectionTestResult?.error).toBe('ECONNREFUSED');
|
||||
}));
|
||||
|
||||
it('should enable integration', fakeAsync(() => {
|
||||
const disabledIntegration = { ...mockIntegration, status: IntegrationStatus.Disabled };
|
||||
component.integration = disabledIntegration;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.enableIntegration();
|
||||
tick();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations/1/enable');
|
||||
expect(req.request.method).toBe('POST');
|
||||
req.flush({ ...mockIntegration, status: IntegrationStatus.Active });
|
||||
|
||||
tick();
|
||||
|
||||
expect(component.integration?.status).toBe(IntegrationStatus.Active);
|
||||
}));
|
||||
|
||||
it('should disable integration', fakeAsync(() => {
|
||||
component.integration = mockIntegration;
|
||||
fixture.detectChanges();
|
||||
|
||||
component.disableIntegration();
|
||||
tick();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations/1/disable');
|
||||
expect(req.request.method).toBe('POST');
|
||||
req.flush({ ...mockIntegration, status: IntegrationStatus.Disabled });
|
||||
|
||||
tick();
|
||||
|
||||
expect(component.integration?.status).toBe(IntegrationStatus.Disabled);
|
||||
}));
|
||||
|
||||
it('should display configuration fields', () => {
|
||||
component.integration = mockIntegration;
|
||||
fixture.detectChanges();
|
||||
|
||||
const configKeys = component.getConfigurationKeys();
|
||||
expect(configKeys).toContain('endpoint');
|
||||
expect(configKeys).toContain('username');
|
||||
expect(component.lastTestResult?.success).toBeFalse();
|
||||
expect(component.lastTestResult?.message).toContain('ENOTFOUND');
|
||||
expect(component.lastTestResult?.duration).toBe('00:00:00.1000000');
|
||||
});
|
||||
|
||||
it('should mask sensitive configuration values', () => {
|
||||
component.integration = {
|
||||
...mockIntegration,
|
||||
configuration: {
|
||||
endpoint: 'https://harbor.example.com',
|
||||
password: 'authref://vault/harbor#password'
|
||||
}
|
||||
};
|
||||
fixture.detectChanges();
|
||||
it('captures health results using checkedAt and message fields', () => {
|
||||
component.checkHealth();
|
||||
|
||||
expect(component.getDisplayValue('password', 'authref://vault/harbor#password')).toBe('••••••••');
|
||||
expect(component.getDisplayValue('endpoint', 'https://harbor.example.com')).toBe('https://harbor.example.com');
|
||||
});
|
||||
|
||||
it('should calculate health status correctly', () => {
|
||||
component.integration = mockIntegration;
|
||||
|
||||
expect(component.getHealthStatusClass()).toBe('status-healthy');
|
||||
|
||||
component.integration = { ...mockIntegration, healthStatus: 'unhealthy' };
|
||||
expect(component.getHealthStatusClass()).toBe('status-unhealthy');
|
||||
|
||||
component.integration = { ...mockIntegration, healthStatus: 'unknown' };
|
||||
expect(component.getHealthStatusClass()).toBe('status-unknown');
|
||||
});
|
||||
|
||||
it('should format last health check time', () => {
|
||||
component.integration = mockIntegration;
|
||||
|
||||
const formatted = component.formatLastHealthCheck();
|
||||
expect(formatted).toBeTruthy();
|
||||
expect(typeof formatted).toBe('string');
|
||||
});
|
||||
|
||||
it('should emit close event', () => {
|
||||
const closeSpy = spyOn(component.closed, 'emit');
|
||||
component.close();
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
expect(component.lastHealthResult?.status).toBe(HealthStatus.Unhealthy);
|
||||
expect(component.lastHealthResult?.message).toContain('ENOTFOUND');
|
||||
expect(component.lastHealthResult?.checkedAt).toBe('2026-03-14T10:06:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,13 @@ import { timeout } from 'rxjs';
|
||||
import { IntegrationService } from './integration.service';
|
||||
import { integrationWorkspaceCommands } from './integration-route-context';
|
||||
import {
|
||||
HealthStatus,
|
||||
Integration,
|
||||
IntegrationHealthResponse,
|
||||
TestConnectionResponse,
|
||||
IntegrationStatus,
|
||||
getHealthStatusColor,
|
||||
getHealthStatusLabel,
|
||||
getIntegrationStatusLabel,
|
||||
getIntegrationStatusColor,
|
||||
getIntegrationTypeLabel,
|
||||
@@ -47,17 +50,17 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<label>Endpoint</label>
|
||||
<span>{{ integration.baseUrl || 'Not configured' }}</span>
|
||||
<span>{{ integration.endpoint || 'Not configured' }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<label>Health</label>
|
||||
<span [class]="'health-badge health-' + (integration.lastTestSuccess ? 'healthy' : 'unhealthy')">
|
||||
{{ integration.lastTestSuccess ? 'Healthy' : 'Unhealthy' }}
|
||||
<span [class]="'health-badge health-' + getHealthColor(integration.lastHealthStatus)">
|
||||
{{ getHealthLabel(integration.lastHealthStatus) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<label>Last Checked</label>
|
||||
<span>{{ integration.lastTestedAt ? (integration.lastTestedAt | date:'medium') : 'Never' }}</span>
|
||||
<span>{{ integration.lastHealthCheckAt ? (integration.lastHealthCheckAt | date:'medium') : 'Never' }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<nav class="detail-tabs">
|
||||
@@ -80,24 +83,24 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
|
||||
}
|
||||
<h3>Configuration</h3>
|
||||
<dl class="config-list">
|
||||
<dt>Tenant</dt>
|
||||
<dd>{{ integration.tenantId || 'Not set' }}</dd>
|
||||
<dt>Organization</dt>
|
||||
<dd>{{ integration.organizationId || 'Not set' }}</dd>
|
||||
<dt>Has Auth</dt>
|
||||
<dd>{{ integration.authRef ? 'Yes (AuthRef)' : 'No' }}</dd>
|
||||
<dd>{{ integration.hasAuth ? 'Configured via AuthRef' : 'Not configured' }}</dd>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ integration.createdAt | date:'medium' }} by {{ integration.createdBy || 'system' }}</dd>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ integration.modifiedAt ? (integration.modifiedAt | date:'medium') : 'Never' }} by {{ integration.modifiedBy || 'system' }}</dd>
|
||||
<dd>{{ integration.updatedAt ? (integration.updatedAt | date:'medium') : 'Never' }} by {{ integration.updatedBy || 'system' }}</dd>
|
||||
</dl>
|
||||
<h3>Tags</h3>
|
||||
@if (integration.tags) {
|
||||
@if (integration.tags.length > 0) {
|
||||
<div class="tags">
|
||||
@for (tag of getTagsArray(integration.tags); track tag) {
|
||||
@for (tag of integration.tags; track tag) {
|
||||
<span class="tag">{{ tag }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (!integration.tags) {
|
||||
@if (integration.tags.length === 0) {
|
||||
<p class="placeholder">No tags.</p>
|
||||
}
|
||||
</div>
|
||||
@@ -106,12 +109,12 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
|
||||
<div class="tab-panel">
|
||||
<h2>Credentials</h2>
|
||||
<dl class="config-list">
|
||||
<dt>Auth Reference</dt>
|
||||
<dd>{{ integration.authRef || 'Not configured' }}</dd>
|
||||
<dt>Credential Mode</dt>
|
||||
<dd>{{ integration.hasAuth ? 'AuthRef-backed' : 'No credential reference configured' }}</dd>
|
||||
<dt>Credential Status</dt>
|
||||
<dd>{{ integration.lastTestSuccess ? 'Valid on last check' : 'Requires attention' }}</dd>
|
||||
<dd>{{ integration.hasAuth ? 'Stored outside StellaOps and resolved on demand.' : 'Requires an AuthRef URI before tests can succeed.' }}</dd>
|
||||
<dt>Last Validation</dt>
|
||||
<dd>{{ integration.lastTestedAt ? (integration.lastTestedAt | date:'medium') : 'Never' }}</dd>
|
||||
<dd>{{ integration.lastHealthCheckAt ? (integration.lastHealthCheckAt | date:'medium') : 'Never' }}</dd>
|
||||
<dt>Rotation</dt>
|
||||
<dd>Managed by integration owner workflow.</dd>
|
||||
</dl>
|
||||
@@ -172,18 +175,18 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
|
||||
<span [innerHTML]="lastTestResult.success ? successIconSvg : failureIconSvg"></span>
|
||||
{{ lastTestResult.success ? 'Success' : 'Failed' }}
|
||||
</div>
|
||||
<p>{{ lastTestResult.errorMessage || 'Connection successful' }}</p>
|
||||
<small>Tested at {{ lastTestResult.testedAt | date:'medium' }} ({{ lastTestResult.latencyMs || 0 }}ms)</small>
|
||||
<p>{{ lastTestResult.message || 'Connection successful.' }}</p>
|
||||
<small>Tested at {{ lastTestResult.testedAt | date:'medium' }} (duration {{ lastTestResult.duration }})</small>
|
||||
</div>
|
||||
}
|
||||
@if (lastHealthResult) {
|
||||
<div class="result-card">
|
||||
<h3>Last Health Check</h3>
|
||||
<div [class]="'health-badge health-' + getStatusColor(lastHealthResult.status)">
|
||||
{{ getStatusLabel(lastHealthResult.status) }}
|
||||
<div [class]="'health-badge health-' + getHealthColor(lastHealthResult.status)">
|
||||
{{ getHealthLabel(lastHealthResult.status) }}
|
||||
</div>
|
||||
<p>{{ lastHealthResult.lastTestSuccess ? 'Service is healthy' : 'Service has issues' }}</p>
|
||||
<small>Checked at {{ lastHealthResult.lastTestedAt | date:'medium' }} ({{ lastHealthResult.averageLatencyMs || 0 }}ms avg)</small>
|
||||
<p>{{ lastHealthResult.message || 'No health detail returned.' }}</p>
|
||||
<small>Checked at {{ lastHealthResult.checkedAt | date:'medium' }} (duration {{ lastHealthResult.duration }})</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -392,7 +395,7 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
|
||||
.status-active, .health-healthy { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.status-pending, .health-unknown { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
|
||||
.status-failed, .health-unhealthy { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
|
||||
.status-disabled, .health-degraded { background: var(--color-border-primary); color: var(--color-text-primary); }
|
||||
.status-disabled, .status-archived, .health-degraded { background: var(--color-border-primary); color: var(--color-text-primary); }
|
||||
|
||||
.placeholder { color: var(--color-text-secondary); font-style: italic; }
|
||||
.detail-state {
|
||||
@@ -489,7 +492,7 @@ export class IntegrationDetailComponent implements OnInit {
|
||||
testConnection(): void {
|
||||
if (!this.integration) return;
|
||||
this.testing = true;
|
||||
this.integrationService.testConnection(this.integration.integrationId).pipe(
|
||||
this.integrationService.testConnection(this.integration.id).pipe(
|
||||
timeout({ first: this.requestTimeoutMs }),
|
||||
).subscribe({
|
||||
next: (result) => {
|
||||
@@ -497,7 +500,7 @@ export class IntegrationDetailComponent implements OnInit {
|
||||
this.lastTestResult = result;
|
||||
this.testing = false;
|
||||
});
|
||||
this.loadIntegration(this.integration!.integrationId);
|
||||
this.loadIntegration(this.integration!.id);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Test connection failed:', err);
|
||||
@@ -511,7 +514,7 @@ export class IntegrationDetailComponent implements OnInit {
|
||||
checkHealth(): void {
|
||||
if (!this.integration) return;
|
||||
this.checking = true;
|
||||
this.integrationService.getHealth(this.integration.integrationId).pipe(
|
||||
this.integrationService.getHealth(this.integration.id).pipe(
|
||||
timeout({ first: this.requestTimeoutMs }),
|
||||
).subscribe({
|
||||
next: (result) => {
|
||||
@@ -519,7 +522,7 @@ export class IntegrationDetailComponent implements OnInit {
|
||||
this.lastHealthResult = result;
|
||||
this.checking = false;
|
||||
});
|
||||
this.loadIntegration(this.integration!.integrationId);
|
||||
this.loadIntegration(this.integration!.id);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Health check failed:', err);
|
||||
@@ -539,6 +542,14 @@ export class IntegrationDetailComponent implements OnInit {
|
||||
return getIntegrationStatusColor(status);
|
||||
}
|
||||
|
||||
getHealthLabel(status: HealthStatus): string {
|
||||
return getHealthStatusLabel(status);
|
||||
}
|
||||
|
||||
getHealthColor(status: HealthStatus): string {
|
||||
return getHealthStatusColor(status);
|
||||
}
|
||||
|
||||
getTypeLabel(type: number): string {
|
||||
return getIntegrationTypeLabel(type);
|
||||
}
|
||||
@@ -547,17 +558,13 @@ export class IntegrationDetailComponent implements OnInit {
|
||||
return getProviderLabel(provider);
|
||||
}
|
||||
|
||||
getTagsArray(tags: string): string[] {
|
||||
return tags ? tags.split(',').map(t => t.trim()).filter(t => t) : [];
|
||||
}
|
||||
|
||||
integrationHubRoute(): string[] {
|
||||
return this.integrationCommands();
|
||||
}
|
||||
|
||||
editIntegration(): void {
|
||||
if (!this.integration) return;
|
||||
void this.router.navigate(this.integrationCommands(this.integration.integrationId), {
|
||||
void this.router.navigate(this.integrationCommands(this.integration.id), {
|
||||
queryParams: { edit: '1' },
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
@@ -566,7 +573,7 @@ export class IntegrationDetailComponent implements OnInit {
|
||||
deleteIntegration(): void {
|
||||
if (!this.integration) return;
|
||||
if (confirm('Are you sure you want to delete this integration?')) {
|
||||
this.integrationService.delete(this.integration.integrationId).subscribe({
|
||||
this.integrationService.delete(this.integration.id).subscribe({
|
||||
next: () => {
|
||||
void this.router.navigate(this.integrationCommands());
|
||||
},
|
||||
|
||||
@@ -201,6 +201,9 @@ export class IntegrationHubComponent {
|
||||
}
|
||||
|
||||
addIntegration(): void {
|
||||
void this.router.navigate(['onboarding'], { relativeTo: this.route });
|
||||
void this.router.navigate(['onboarding'], {
|
||||
relativeTo: this.route,
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,143 +1,113 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { IntegrationListComponent } from './integration-list.component';
|
||||
import { ActivatedRoute, Router, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
|
||||
import { IntegrationService } from './integration.service';
|
||||
import { Integration, IntegrationType, IntegrationStatus } from './integration.models';
|
||||
import {
|
||||
HealthStatus,
|
||||
IntegrationProvider,
|
||||
IntegrationStatus,
|
||||
IntegrationType,
|
||||
} from './integration.models';
|
||||
import { IntegrationListComponent } from './integration-list.component';
|
||||
|
||||
@Component({
|
||||
selector: 'st-doctor-checks-inline',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class DoctorChecksInlineStubComponent {
|
||||
readonly category = input<string>('');
|
||||
readonly heading = input<string>('');
|
||||
}
|
||||
|
||||
describe('IntegrationListComponent', () => {
|
||||
let component: IntegrationListComponent;
|
||||
let fixture: ComponentFixture<IntegrationListComponent>;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
const mockIntegrations: Integration[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Harbor Registry',
|
||||
type: IntegrationType.Registry,
|
||||
provider: 'harbor',
|
||||
status: IntegrationStatus.Active,
|
||||
description: 'Main container registry',
|
||||
tags: ['production'],
|
||||
configuration: { endpoint: 'https://harbor.example.com' },
|
||||
createdAt: '2025-12-29T12:00:00Z',
|
||||
updatedAt: '2025-12-29T12:00:00Z',
|
||||
createdBy: 'admin'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'GitHub App',
|
||||
type: IntegrationType.Scm,
|
||||
provider: 'github-app',
|
||||
status: IntegrationStatus.Error,
|
||||
description: 'Source control integration',
|
||||
tags: ['dev'],
|
||||
configuration: { appId: '12345' },
|
||||
createdAt: '2025-12-28T10:00:00Z',
|
||||
updatedAt: '2025-12-29T10:00:00Z',
|
||||
createdBy: 'admin',
|
||||
lastError: 'Authentication failed'
|
||||
}
|
||||
];
|
||||
let component: IntegrationListComponent;
|
||||
let integrationService: jasmine.SpyObj<IntegrationService>;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(async () => {
|
||||
integrationService = jasmine.createSpyObj<IntegrationService>('IntegrationService', ['list', 'testConnection', 'getHealth']);
|
||||
integrationService.list.and.returnValue(of({
|
||||
items: [
|
||||
{
|
||||
id: 'int-1',
|
||||
name: 'Harbor Registry',
|
||||
description: 'Main registry',
|
||||
type: IntegrationType.Registry,
|
||||
provider: IntegrationProvider.Harbor,
|
||||
status: IntegrationStatus.Active,
|
||||
endpoint: 'https://harbor.example.com',
|
||||
hasAuth: true,
|
||||
organizationId: 'platform',
|
||||
lastHealthStatus: HealthStatus.Healthy,
|
||||
lastHealthCheckAt: '2026-03-14T10:00:00Z',
|
||||
createdAt: '2026-03-14T09:00:00Z',
|
||||
updatedAt: '2026-03-14T10:00:00Z',
|
||||
createdBy: 'demo-user',
|
||||
updatedBy: 'demo-user',
|
||||
tags: ['prod'],
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 1,
|
||||
}));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IntegrationListComponent],
|
||||
providers: [
|
||||
IntegrationService,
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting()
|
||||
]
|
||||
}).compileComponents();
|
||||
provideRouter([]),
|
||||
{ provide: IntegrationService, useValue: integrationService },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
data: { type: 'Registry' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(IntegrationListComponent, {
|
||||
remove: { imports: [DoctorChecksInlineComponent] },
|
||||
add: { imports: [DoctorChecksInlineStubComponent] },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(IntegrationListComponent);
|
||||
component = fixture.componentInstance;
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load integrations on init', () => {
|
||||
router = TestBed.inject(Router);
|
||||
fixture.detectChanges();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations');
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(mockIntegrations);
|
||||
|
||||
expect(component.integrations.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should filter integrations by type', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations');
|
||||
req.flush(mockIntegrations);
|
||||
|
||||
component.filterByType(IntegrationType.Registry);
|
||||
|
||||
expect(component.filteredIntegrations.length).toBe(1);
|
||||
expect(component.filteredIntegrations[0].type).toBe(IntegrationType.Registry);
|
||||
it('loads canonical list responses and renders health from lastHealthStatus', () => {
|
||||
expect(component.integrations.length).toBe(1);
|
||||
expect(component.integrations[0].id).toBe('int-1');
|
||||
expect(component.getHealthLabel(component.integrations[0].lastHealthStatus)).toBe('Healthy');
|
||||
});
|
||||
|
||||
it('should filter integrations by status', () => {
|
||||
fixture.detectChanges();
|
||||
it('passes backend status filters through the list query', () => {
|
||||
component.filterStatus = IntegrationStatus.Disabled;
|
||||
component.loadIntegrations();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations');
|
||||
req.flush(mockIntegrations);
|
||||
|
||||
component.filterByStatus(IntegrationStatus.Error);
|
||||
|
||||
expect(component.filteredIntegrations.length).toBe(1);
|
||||
expect(component.filteredIntegrations[0].status).toBe(IntegrationStatus.Error);
|
||||
expect(integrationService.list).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
type: IntegrationType.Registry,
|
||||
status: IntegrationStatus.Disabled,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should filter integrations by search text', () => {
|
||||
fixture.detectChanges();
|
||||
it('preserves the current scope query when opening typed onboarding', () => {
|
||||
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations');
|
||||
req.flush(mockIntegrations);
|
||||
component.addIntegration();
|
||||
|
||||
component.searchText = 'Harbor';
|
||||
component.applyFilters();
|
||||
|
||||
expect(component.filteredIntegrations.length).toBe(1);
|
||||
expect(component.filteredIntegrations[0].name).toContain('Harbor');
|
||||
});
|
||||
|
||||
it('should return correct status badge class', () => {
|
||||
expect(component.getStatusBadgeClass(IntegrationStatus.Active)).toBe('badge-success');
|
||||
expect(component.getStatusBadgeClass(IntegrationStatus.Error)).toBe('badge-danger');
|
||||
expect(component.getStatusBadgeClass(IntegrationStatus.Disabled)).toBe('badge-secondary');
|
||||
expect(component.getStatusBadgeClass(IntegrationStatus.Pending)).toBe('badge-warning');
|
||||
});
|
||||
|
||||
it('should emit selection event when integration clicked', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations');
|
||||
req.flush(mockIntegrations);
|
||||
|
||||
const selectSpy = spyOn(component.integrationSelected, 'emit');
|
||||
component.onSelect(mockIntegrations[0]);
|
||||
|
||||
expect(selectSpy).toHaveBeenCalledWith(mockIntegrations[0]);
|
||||
});
|
||||
|
||||
it('should display error count for integrations with errors', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations');
|
||||
req.flush(mockIntegrations);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const errorIntegration = component.integrations.find(i => i.status === IntegrationStatus.Error);
|
||||
expect(errorIntegration?.lastError).toBe('Authentication failed');
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/ops/integrations', 'onboarding', 'registry'], {
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,12 @@ import { IntegrationService } from './integration.service';
|
||||
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
|
||||
import { integrationWorkspaceCommands } from './integration-route-context';
|
||||
import {
|
||||
HealthStatus,
|
||||
Integration,
|
||||
IntegrationType,
|
||||
IntegrationStatus,
|
||||
getHealthStatusColor,
|
||||
getHealthStatusLabel,
|
||||
getIntegrationStatusLabel,
|
||||
getIntegrationStatusColor,
|
||||
getProviderLabel,
|
||||
@@ -32,11 +35,11 @@ import {
|
||||
<section class="filters">
|
||||
<select [(ngModel)]="filterStatus" (change)="loadIntegrations()" class="filter-select">
|
||||
<option [ngValue]="undefined">All Statuses</option>
|
||||
<option [ngValue]="IntegrationStatus.Pending">Pending</option>
|
||||
<option [ngValue]="IntegrationStatus.Active">Active</option>
|
||||
<option [ngValue]="IntegrationStatus.PendingVerification">Pending</option>
|
||||
<option [ngValue]="IntegrationStatus.Degraded">Degraded</option>
|
||||
<option [ngValue]="IntegrationStatus.Paused">Paused</option>
|
||||
<option [ngValue]="IntegrationStatus.Failed">Failed</option>
|
||||
<option [ngValue]="IntegrationStatus.Disabled">Disabled</option>
|
||||
<option [ngValue]="IntegrationStatus.Archived">Archived</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
@@ -77,10 +80,10 @@ import {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (integration of integrations; track integration.integrationId) {
|
||||
@for (integration of integrations; track integration.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<a [routerLink]="integrationDetailRoute(integration.integrationId)">{{ integration.name }}</a>
|
||||
<a [routerLink]="integrationDetailRoute(integration.id)">{{ integration.name }}</a>
|
||||
</td>
|
||||
<td>{{ getProviderName(integration.provider) }}</td>
|
||||
<td>
|
||||
@@ -89,16 +92,16 @@ import {
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span [class]="'health-badge health-' + (integration.lastTestSuccess ? 'healthy' : 'unhealthy')">
|
||||
{{ integration.lastTestSuccess ? 'Healthy' : 'Unhealthy' }}
|
||||
<span [class]="'health-badge health-' + getHealthColor(integration.lastHealthStatus)">
|
||||
{{ getHealthLabel(integration.lastHealthStatus) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ integration.lastTestedAt ? (integration.lastTestedAt | date:'short') : 'Never' }}</td>
|
||||
<td>{{ integration.lastHealthCheckAt ? (integration.lastHealthCheckAt | date:'short') : 'Never' }}</td>
|
||||
<td class="actions">
|
||||
<button (click)="editIntegration(integration)" title="Edit"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg></button>
|
||||
<button (click)="testConnection(integration)" title="Test Connection"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22v-5"/><path d="M9 7V2"/><path d="M15 7V2"/><path d="M6 13V8h12v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4Z"/></svg></button>
|
||||
<button (click)="checkHealth(integration)" title="Check Health"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg></button>
|
||||
<a [routerLink]="integrationDetailRoute(integration.integrationId)" title="View Details"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></a>
|
||||
<a [routerLink]="integrationDetailRoute(integration.id)" title="View Details"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -194,7 +197,7 @@ import {
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.status-disabled, .health-degraded {
|
||||
.status-disabled, .status-archived, .health-degraded {
|
||||
background: var(--color-border-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
@@ -355,9 +358,9 @@ export class IntegrationListComponent implements OnInit {
|
||||
}
|
||||
|
||||
testConnection(integration: Integration): void {
|
||||
this.integrationService.testConnection(integration.integrationId).subscribe({
|
||||
this.integrationService.testConnection(integration.id).subscribe({
|
||||
next: (result) => {
|
||||
alert(result.success ? 'Connection successful!' : `Connection failed: ${result.errorMessage || 'Unknown error'}`);
|
||||
alert(result.success ? `Connection successful: ${result.message || 'Connector responded successfully.'}` : `Connection failed: ${result.message || 'Unknown error'}`);
|
||||
this.loadIntegrations();
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -367,9 +370,9 @@ export class IntegrationListComponent implements OnInit {
|
||||
}
|
||||
|
||||
checkHealth(integration: Integration): void {
|
||||
this.integrationService.getHealth(integration.integrationId).subscribe({
|
||||
this.integrationService.getHealth(integration.id).subscribe({
|
||||
next: (result) => {
|
||||
alert(`Health: ${getIntegrationStatusLabel(result.status)} - ${result.lastTestSuccess ? 'OK' : 'Issues detected'}`);
|
||||
alert(`Health: ${getHealthStatusLabel(result.status)} - ${result.message || 'No detail returned.'}`);
|
||||
this.loadIntegrations();
|
||||
},
|
||||
error: (err) => {
|
||||
@@ -387,6 +390,14 @@ export class IntegrationListComponent implements OnInit {
|
||||
return getIntegrationStatusColor(status);
|
||||
}
|
||||
|
||||
getHealthLabel(status: HealthStatus): string {
|
||||
return getHealthStatusLabel(status);
|
||||
}
|
||||
|
||||
getHealthColor(status: HealthStatus): string {
|
||||
return getHealthStatusColor(status);
|
||||
}
|
||||
|
||||
getProviderName(provider: number): string {
|
||||
return getProviderLabel(provider);
|
||||
}
|
||||
@@ -406,7 +417,7 @@ export class IntegrationListComponent implements OnInit {
|
||||
}
|
||||
|
||||
editIntegration(integration: Integration): void {
|
||||
void this.router.navigate(this.integrationCommands(integration.integrationId), {
|
||||
void this.router.navigate(this.integrationCommands(integration.id), {
|
||||
queryParams: { edit: true },
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
@@ -417,7 +428,9 @@ export class IntegrationListComponent implements OnInit {
|
||||
? this.integrationCommands('onboarding', this.getOnboardingTypeSegment(this.integrationType))
|
||||
: this.integrationCommands('onboarding');
|
||||
|
||||
void this.router.navigate(commands);
|
||||
void this.router.navigate(commands, {
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
}
|
||||
|
||||
private parseType(typeStr: string): IntegrationType | undefined {
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* Integration Catalog Models
|
||||
* Sprint: SPRINT_20251229_011_FE_integration_hub_ui
|
||||
*/
|
||||
|
||||
export enum IntegrationType {
|
||||
Registry = 1,
|
||||
Scm = 2,
|
||||
@@ -14,85 +9,85 @@ export enum IntegrationType {
|
||||
Marketplace = 8,
|
||||
}
|
||||
|
||||
export enum IntegrationStatus {
|
||||
Draft = 0,
|
||||
PendingVerification = 1,
|
||||
Active = 2,
|
||||
Degraded = 3,
|
||||
Paused = 4,
|
||||
Failed = 5,
|
||||
}
|
||||
|
||||
export enum IntegrationProvider {
|
||||
// Registry providers
|
||||
DockerHub = 100,
|
||||
Harbor = 101,
|
||||
Ecr = 102,
|
||||
Harbor = 100,
|
||||
Ecr = 101,
|
||||
Gcr = 102,
|
||||
Acr = 103,
|
||||
Gcr = 104,
|
||||
Ghcr = 105,
|
||||
Quay = 106,
|
||||
JfrogArtifactory = 107,
|
||||
|
||||
// SCM providers
|
||||
GitHub = 200,
|
||||
GitLab = 201,
|
||||
Gitea = 202,
|
||||
Bitbucket = 203,
|
||||
DockerHub = 104,
|
||||
Quay = 105,
|
||||
Artifactory = 106,
|
||||
Nexus = 107,
|
||||
GitHubContainerRegistry = 108,
|
||||
GitLabContainerRegistry = 109,
|
||||
GitHubApp = 200,
|
||||
GitLabServer = 201,
|
||||
Bitbucket = 202,
|
||||
Gitea = 203,
|
||||
AzureDevOps = 204,
|
||||
|
||||
// CI providers
|
||||
GitHubActions = 300,
|
||||
GitLabCi = 301,
|
||||
GiteaActions = 302,
|
||||
Jenkins = 303,
|
||||
CircleCi = 304,
|
||||
AzurePipelines = 305,
|
||||
Jenkins = 302,
|
||||
CircleCi = 303,
|
||||
AzurePipelines = 304,
|
||||
ArgoWorkflows = 305,
|
||||
Tekton = 306,
|
||||
NpmRegistry = 400,
|
||||
PyPi = 401,
|
||||
MavenCentral = 402,
|
||||
NuGetOrg = 403,
|
||||
CratesIo = 404,
|
||||
GoProxy = 405,
|
||||
EbpfAgent = 500,
|
||||
EtwAgent = 501,
|
||||
DyldInterposer = 502,
|
||||
StellaOpsMirror = 600,
|
||||
NvdMirror = 601,
|
||||
OsvMirror = 602,
|
||||
MicrosoftSymbols = 700,
|
||||
UbuntuDebuginfod = 701,
|
||||
FedoraDebuginfod = 702,
|
||||
DebianDebuginfod = 703,
|
||||
PartnerSymbols = 704,
|
||||
CommunityFixes = 800,
|
||||
PartnerFixes = 801,
|
||||
VendorFixes = 802,
|
||||
InMemory = 900,
|
||||
Custom = 999,
|
||||
}
|
||||
|
||||
// Host providers
|
||||
ZastavaEbpf = 400,
|
||||
ZastavaEtw = 401,
|
||||
ZastavaDyld = 402,
|
||||
export enum IntegrationStatus {
|
||||
Pending = 0,
|
||||
Active = 1,
|
||||
Failed = 2,
|
||||
Disabled = 3,
|
||||
Archived = 4,
|
||||
}
|
||||
|
||||
// Feed providers
|
||||
Concelier = 500,
|
||||
Excititor = 501,
|
||||
|
||||
// Artifact providers
|
||||
SbomUpload = 600,
|
||||
VexUpload = 601,
|
||||
export enum HealthStatus {
|
||||
Unknown = 0,
|
||||
Healthy = 1,
|
||||
Degraded = 2,
|
||||
Unhealthy = 3,
|
||||
}
|
||||
|
||||
export interface Integration {
|
||||
integrationId: string;
|
||||
tenantId: string;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
description?: string | null;
|
||||
type: IntegrationType;
|
||||
provider: IntegrationProvider;
|
||||
status: IntegrationStatus;
|
||||
baseUrl?: string;
|
||||
authRef?: string;
|
||||
configuration?: Record<string, unknown>;
|
||||
environment?: string;
|
||||
tags?: string;
|
||||
ownerId?: string;
|
||||
endpoint: string;
|
||||
hasAuth: boolean;
|
||||
organizationId?: string | null;
|
||||
lastHealthStatus: HealthStatus;
|
||||
lastHealthCheckAt?: string | null;
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
modifiedAt?: string;
|
||||
modifiedBy?: string;
|
||||
lastTestedAt?: string;
|
||||
lastTestSuccess?: boolean;
|
||||
lastTestError?: string;
|
||||
lastSyncAt?: string;
|
||||
lastEventAt?: string;
|
||||
paused: boolean;
|
||||
pauseReason?: string;
|
||||
pauseTicket?: string;
|
||||
pausedAt?: string;
|
||||
pausedBy?: string;
|
||||
consecutiveFailures: number;
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
createdBy?: string | null;
|
||||
updatedBy?: string | null;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface IntegrationListResponse {
|
||||
@@ -100,59 +95,56 @@ export interface IntegrationListResponse {
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface CreateIntegrationRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
description?: string | null;
|
||||
type: IntegrationType;
|
||||
provider: IntegrationProvider;
|
||||
baseUrl?: string;
|
||||
authRef?: string;
|
||||
configuration?: Record<string, unknown>;
|
||||
environment?: string;
|
||||
tags?: string;
|
||||
ownerId?: string;
|
||||
endpoint: string;
|
||||
authRefUri?: string | null;
|
||||
organizationId?: string | null;
|
||||
extendedConfig?: Record<string, unknown> | null;
|
||||
tags?: string[] | null;
|
||||
}
|
||||
|
||||
export interface UpdateIntegrationRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
baseUrl?: string;
|
||||
authRef?: string;
|
||||
configuration?: Record<string, unknown>;
|
||||
environment?: string;
|
||||
tags?: string;
|
||||
ownerId?: string;
|
||||
}
|
||||
|
||||
export interface PauseIntegrationRequest {
|
||||
reason: string;
|
||||
ticket?: string;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
endpoint?: string | null;
|
||||
authRefUri?: string | null;
|
||||
organizationId?: string | null;
|
||||
extendedConfig?: Record<string, unknown> | null;
|
||||
tags?: string[] | null;
|
||||
status?: IntegrationStatus | null;
|
||||
}
|
||||
|
||||
export interface TestConnectionResponse {
|
||||
integrationId: string;
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
message?: string | null;
|
||||
details?: Record<string, string> | null;
|
||||
duration: string;
|
||||
testedAt: string;
|
||||
latencyMs?: number;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IntegrationHealthResponse {
|
||||
integrationId: string;
|
||||
status: IntegrationStatus;
|
||||
lastTestedAt?: string;
|
||||
lastTestSuccess?: boolean;
|
||||
lastSyncAt?: string;
|
||||
lastEventAt?: string;
|
||||
consecutiveFailures: number;
|
||||
uptimePercentage?: number;
|
||||
averageLatencyMs?: number;
|
||||
status: HealthStatus;
|
||||
message?: string | null;
|
||||
details?: Record<string, string> | null;
|
||||
checkedAt: string;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
export interface SupportedProviderInfo {
|
||||
name: string;
|
||||
type: IntegrationType;
|
||||
provider: IntegrationProvider;
|
||||
}
|
||||
|
||||
// Display helpers
|
||||
export function getIntegrationTypeLabel(type: IntegrationType): string {
|
||||
switch (type) {
|
||||
case IntegrationType.Registry:
|
||||
@@ -162,7 +154,7 @@ export function getIntegrationTypeLabel(type: IntegrationType): string {
|
||||
case IntegrationType.CiCd:
|
||||
return 'CI/CD';
|
||||
case IntegrationType.RepoSource:
|
||||
return 'Repo Source';
|
||||
return 'Repository Source';
|
||||
case IntegrationType.RuntimeHost:
|
||||
return 'Runtime Host';
|
||||
case IntegrationType.FeedMirror:
|
||||
@@ -178,18 +170,16 @@ export function getIntegrationTypeLabel(type: IntegrationType): string {
|
||||
|
||||
export function getIntegrationStatusLabel(status: IntegrationStatus): string {
|
||||
switch (status) {
|
||||
case IntegrationStatus.Draft:
|
||||
return 'Draft';
|
||||
case IntegrationStatus.PendingVerification:
|
||||
case IntegrationStatus.Pending:
|
||||
return 'Pending';
|
||||
case IntegrationStatus.Active:
|
||||
return 'Active';
|
||||
case IntegrationStatus.Degraded:
|
||||
return 'Degraded';
|
||||
case IntegrationStatus.Paused:
|
||||
return 'Paused';
|
||||
case IntegrationStatus.Failed:
|
||||
return 'Failed';
|
||||
case IntegrationStatus.Disabled:
|
||||
return 'Disabled';
|
||||
case IntegrationStatus.Archived:
|
||||
return 'Archived';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
@@ -197,76 +187,139 @@ export function getIntegrationStatusLabel(status: IntegrationStatus): string {
|
||||
|
||||
export function getIntegrationStatusColor(status: IntegrationStatus): string {
|
||||
switch (status) {
|
||||
case IntegrationStatus.Pending:
|
||||
return 'pending';
|
||||
case IntegrationStatus.Active:
|
||||
return 'success';
|
||||
case IntegrationStatus.Draft:
|
||||
case IntegrationStatus.PendingVerification:
|
||||
return 'info';
|
||||
case IntegrationStatus.Degraded:
|
||||
return 'warning';
|
||||
case IntegrationStatus.Paused:
|
||||
return 'secondary';
|
||||
return 'active';
|
||||
case IntegrationStatus.Failed:
|
||||
return 'danger';
|
||||
return 'failed';
|
||||
case IntegrationStatus.Disabled:
|
||||
return 'disabled';
|
||||
case IntegrationStatus.Archived:
|
||||
return 'archived';
|
||||
default:
|
||||
return 'secondary';
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export function getHealthStatusLabel(status: HealthStatus): string {
|
||||
switch (status) {
|
||||
case HealthStatus.Healthy:
|
||||
return 'Healthy';
|
||||
case HealthStatus.Degraded:
|
||||
return 'Degraded';
|
||||
case HealthStatus.Unhealthy:
|
||||
return 'Unhealthy';
|
||||
case HealthStatus.Unknown:
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export function getHealthStatusColor(status: HealthStatus): string {
|
||||
switch (status) {
|
||||
case HealthStatus.Healthy:
|
||||
return 'healthy';
|
||||
case HealthStatus.Degraded:
|
||||
return 'degraded';
|
||||
case HealthStatus.Unhealthy:
|
||||
return 'unhealthy';
|
||||
case HealthStatus.Unknown:
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export function getProviderLabel(provider: IntegrationProvider): string {
|
||||
switch (provider) {
|
||||
case IntegrationProvider.DockerHub:
|
||||
return 'Docker Hub';
|
||||
case IntegrationProvider.Harbor:
|
||||
return 'Harbor';
|
||||
case IntegrationProvider.Ecr:
|
||||
return 'AWS ECR';
|
||||
case IntegrationProvider.Acr:
|
||||
return 'Azure ACR';
|
||||
case IntegrationProvider.Gcr:
|
||||
return 'Google GCR';
|
||||
case IntegrationProvider.Ghcr:
|
||||
return 'GitHub GHCR';
|
||||
case IntegrationProvider.Acr:
|
||||
return 'Azure ACR';
|
||||
case IntegrationProvider.DockerHub:
|
||||
return 'Docker Hub';
|
||||
case IntegrationProvider.Quay:
|
||||
return 'Quay.io';
|
||||
case IntegrationProvider.JfrogArtifactory:
|
||||
return 'JFrog Artifactory';
|
||||
case IntegrationProvider.GitHub:
|
||||
return 'GitHub';
|
||||
case IntegrationProvider.GitLab:
|
||||
return 'GitLab';
|
||||
case IntegrationProvider.Gitea:
|
||||
return 'Gitea';
|
||||
return 'Quay';
|
||||
case IntegrationProvider.Artifactory:
|
||||
return 'Artifactory';
|
||||
case IntegrationProvider.Nexus:
|
||||
return 'Nexus';
|
||||
case IntegrationProvider.GitHubContainerRegistry:
|
||||
return 'GitHub Container Registry';
|
||||
case IntegrationProvider.GitLabContainerRegistry:
|
||||
return 'GitLab Container Registry';
|
||||
case IntegrationProvider.GitHubApp:
|
||||
return 'GitHub App';
|
||||
case IntegrationProvider.GitLabServer:
|
||||
return 'GitLab Server';
|
||||
case IntegrationProvider.Bitbucket:
|
||||
return 'Bitbucket';
|
||||
case IntegrationProvider.Gitea:
|
||||
return 'Gitea';
|
||||
case IntegrationProvider.AzureDevOps:
|
||||
return 'Azure DevOps';
|
||||
case IntegrationProvider.GitHubActions:
|
||||
return 'GitHub Actions';
|
||||
case IntegrationProvider.GitLabCi:
|
||||
return 'GitLab CI';
|
||||
case IntegrationProvider.GiteaActions:
|
||||
return 'Gitea Actions';
|
||||
case IntegrationProvider.Jenkins:
|
||||
return 'Jenkins';
|
||||
case IntegrationProvider.CircleCi:
|
||||
return 'CircleCI';
|
||||
case IntegrationProvider.AzurePipelines:
|
||||
return 'Azure Pipelines';
|
||||
case IntegrationProvider.ZastavaEbpf:
|
||||
return 'Zastava (eBPF)';
|
||||
case IntegrationProvider.ZastavaEtw:
|
||||
return 'Zastava (ETW)';
|
||||
case IntegrationProvider.ZastavaDyld:
|
||||
return 'Zastava (dyld)';
|
||||
case IntegrationProvider.Concelier:
|
||||
return 'Concelier';
|
||||
case IntegrationProvider.Excititor:
|
||||
return 'Excititor';
|
||||
case IntegrationProvider.SbomUpload:
|
||||
return 'SBOM Upload';
|
||||
case IntegrationProvider.VexUpload:
|
||||
return 'VEX Upload';
|
||||
case IntegrationProvider.ArgoWorkflows:
|
||||
return 'Argo Workflows';
|
||||
case IntegrationProvider.Tekton:
|
||||
return 'Tekton';
|
||||
case IntegrationProvider.NpmRegistry:
|
||||
return 'npm Registry';
|
||||
case IntegrationProvider.PyPi:
|
||||
return 'PyPI';
|
||||
case IntegrationProvider.MavenCentral:
|
||||
return 'Maven Central';
|
||||
case IntegrationProvider.NuGetOrg:
|
||||
return 'NuGet.org';
|
||||
case IntegrationProvider.CratesIo:
|
||||
return 'crates.io';
|
||||
case IntegrationProvider.GoProxy:
|
||||
return 'Go Proxy';
|
||||
case IntegrationProvider.EbpfAgent:
|
||||
return 'eBPF Agent';
|
||||
case IntegrationProvider.EtwAgent:
|
||||
return 'ETW Agent';
|
||||
case IntegrationProvider.DyldInterposer:
|
||||
return 'dyld Interposer';
|
||||
case IntegrationProvider.StellaOpsMirror:
|
||||
return 'StellaOps Mirror';
|
||||
case IntegrationProvider.NvdMirror:
|
||||
return 'NVD Mirror';
|
||||
case IntegrationProvider.OsvMirror:
|
||||
return 'OSV Mirror';
|
||||
case IntegrationProvider.MicrosoftSymbols:
|
||||
return 'Microsoft Symbols';
|
||||
case IntegrationProvider.UbuntuDebuginfod:
|
||||
return 'Ubuntu Debuginfod';
|
||||
case IntegrationProvider.FedoraDebuginfod:
|
||||
return 'Fedora Debuginfod';
|
||||
case IntegrationProvider.DebianDebuginfod:
|
||||
return 'Debian Debuginfod';
|
||||
case IntegrationProvider.PartnerSymbols:
|
||||
return 'Partner Symbols';
|
||||
case IntegrationProvider.CommunityFixes:
|
||||
return 'Community Fixes';
|
||||
case IntegrationProvider.PartnerFixes:
|
||||
return 'Partner Fixes';
|
||||
case IntegrationProvider.VendorFixes:
|
||||
return 'Vendor Fixes';
|
||||
case IntegrationProvider.InMemory:
|
||||
return 'In-Memory';
|
||||
case IntegrationProvider.Custom:
|
||||
return 'Custom';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
@@ -1,34 +1,26 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
|
||||
import { IntegrationService } from './integration.service';
|
||||
import { Integration, IntegrationType, IntegrationStatus, ConnectionTestResult } from './integration.models';
|
||||
import {
|
||||
HealthStatus,
|
||||
IntegrationProvider,
|
||||
IntegrationStatus,
|
||||
IntegrationType,
|
||||
} from './integration.models';
|
||||
|
||||
describe('IntegrationService', () => {
|
||||
let service: IntegrationService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
const mockIntegration: Integration = {
|
||||
id: '1',
|
||||
name: 'Harbor Registry',
|
||||
type: IntegrationType.Registry,
|
||||
provider: 'harbor',
|
||||
status: IntegrationStatus.Active,
|
||||
description: 'Test',
|
||||
tags: [],
|
||||
configuration: {},
|
||||
createdAt: '2025-12-29T12:00:00Z',
|
||||
updatedAt: '2025-12-29T12:00:00Z',
|
||||
createdBy: 'admin'
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
IntegrationService,
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting()
|
||||
]
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(IntegrationService);
|
||||
@@ -39,155 +31,84 @@ describe('IntegrationService', () => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
it('lists integrations with canonical query parameters', () => {
|
||||
service.list({
|
||||
type: IntegrationType.Registry,
|
||||
status: IntegrationStatus.Active,
|
||||
provider: IntegrationProvider.Harbor,
|
||||
search: 'harbor',
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
}).subscribe();
|
||||
|
||||
const req = httpMock.expectOne((request) =>
|
||||
request.url === '/api/v1/integrations'
|
||||
&& request.params.get('type') === '1'
|
||||
&& request.params.get('status') === '1'
|
||||
&& request.params.get('provider') === '100'
|
||||
&& request.params.get('search') === 'harbor'
|
||||
&& request.params.get('page') === '2'
|
||||
&& request.params.get('pageSize') === '10');
|
||||
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush({ items: [], totalCount: 0, page: 2, pageSize: 10, totalPages: 0 });
|
||||
});
|
||||
|
||||
describe('getIntegrations', () => {
|
||||
it('should fetch all integrations', () => {
|
||||
const mockIntegrations = [mockIntegration];
|
||||
it('creates integrations against the canonical API contract', () => {
|
||||
const request = {
|
||||
name: 'Production Harbor',
|
||||
description: null,
|
||||
type: IntegrationType.Registry,
|
||||
provider: IntegrationProvider.Harbor,
|
||||
endpoint: 'https://harbor.example.com',
|
||||
authRefUri: 'authref://vault/harbor#robot',
|
||||
organizationId: 'platform',
|
||||
extendedConfig: { namespaces: ['platform'] },
|
||||
tags: ['prod'],
|
||||
} as const;
|
||||
|
||||
service.getIntegrations().subscribe(integrations => {
|
||||
expect(integrations.length).toBe(1);
|
||||
expect(integrations[0].name).toBe('Harbor Registry');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations');
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(mockIntegrations);
|
||||
service.create(request).subscribe((integration) => {
|
||||
expect(integration.id).toBe('int-1');
|
||||
expect(integration.endpoint).toBe('https://harbor.example.com');
|
||||
expect(integration.hasAuth).toBeTrue();
|
||||
});
|
||||
|
||||
it('should filter by type', () => {
|
||||
service.getIntegrations(IntegrationType.Registry).subscribe();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations?type=ContainerRegistry');
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
it('should filter by status', () => {
|
||||
service.getIntegrations(undefined, IntegrationStatus.Active).subscribe();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations?status=Active');
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush([]);
|
||||
const req = httpMock.expectOne('/api/v1/integrations');
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual(request);
|
||||
req.flush({
|
||||
id: 'int-1',
|
||||
name: 'Production Harbor',
|
||||
description: null,
|
||||
type: IntegrationType.Registry,
|
||||
provider: IntegrationProvider.Harbor,
|
||||
status: IntegrationStatus.Pending,
|
||||
endpoint: 'https://harbor.example.com',
|
||||
hasAuth: true,
|
||||
organizationId: 'platform',
|
||||
lastHealthStatus: HealthStatus.Unknown,
|
||||
lastHealthCheckAt: null,
|
||||
createdAt: '2026-03-14T10:00:00Z',
|
||||
updatedAt: '2026-03-14T10:00:00Z',
|
||||
createdBy: 'demo-user',
|
||||
updatedBy: 'demo-user',
|
||||
tags: ['prod'],
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIntegration', () => {
|
||||
it('should fetch a single integration by id', () => {
|
||||
service.getIntegration('1').subscribe(integration => {
|
||||
expect(integration.id).toBe('1');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations/1');
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(mockIntegration);
|
||||
it('retrieves the supported provider catalog from the canonical endpoint', () => {
|
||||
service.getSupportedProviders().subscribe((providers) => {
|
||||
expect(providers).toEqual([
|
||||
{ name: 'harbor', type: IntegrationType.Registry, provider: IntegrationProvider.Harbor },
|
||||
{ name: 'github-app', type: IntegrationType.Scm, provider: IntegrationProvider.GitHubApp },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createIntegration', () => {
|
||||
it('should create a new integration', () => {
|
||||
const createRequest = {
|
||||
name: 'New Registry',
|
||||
type: IntegrationType.Registry,
|
||||
provider: 'harbor',
|
||||
configuration: { endpoint: 'https://new.example.com' }
|
||||
};
|
||||
|
||||
service.createIntegration(createRequest).subscribe(integration => {
|
||||
expect(integration.name).toBe('New Registry');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations');
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual(createRequest);
|
||||
req.flush({ ...mockIntegration, ...createRequest });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateIntegration', () => {
|
||||
it('should update an existing integration', () => {
|
||||
const updateRequest = { name: 'Updated Name' };
|
||||
|
||||
service.updateIntegration('1', updateRequest).subscribe(integration => {
|
||||
expect(integration.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations/1');
|
||||
expect(req.request.method).toBe('PUT');
|
||||
req.flush({ ...mockIntegration, name: 'Updated Name' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteIntegration', () => {
|
||||
it('should delete an integration', () => {
|
||||
service.deleteIntegration('1').subscribe(result => {
|
||||
expect(result).toBeTrue();
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations/1');
|
||||
expect(req.request.method).toBe('DELETE');
|
||||
req.flush(null, { status: 204, statusText: 'No Content' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
it('should test connection and return result', () => {
|
||||
const testResult: ConnectionTestResult = {
|
||||
success: true,
|
||||
message: 'Connected',
|
||||
latencyMs: 50
|
||||
};
|
||||
|
||||
service.testConnection('1').subscribe(result => {
|
||||
expect(result.success).toBeTrue();
|
||||
expect(result.latencyMs).toBe(50);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations/1/test-connection');
|
||||
expect(req.request.method).toBe('POST');
|
||||
req.flush(testResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableIntegration', () => {
|
||||
it('should enable an integration', () => {
|
||||
service.enableIntegration('1').subscribe(integration => {
|
||||
expect(integration.status).toBe(IntegrationStatus.Active);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations/1/enable');
|
||||
expect(req.request.method).toBe('POST');
|
||||
req.flush({ ...mockIntegration, status: IntegrationStatus.Active });
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableIntegration', () => {
|
||||
it('should disable an integration', () => {
|
||||
service.disableIntegration('1').subscribe(integration => {
|
||||
expect(integration.status).toBe(IntegrationStatus.Disabled);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations/1/disable');
|
||||
expect(req.request.method).toBe('POST');
|
||||
req.flush({ ...mockIntegration, status: IntegrationStatus.Disabled });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActivityLogs', () => {
|
||||
it('should fetch activity logs for an integration', () => {
|
||||
const mockLogs = [
|
||||
{ id: '1', timestamp: '2025-12-29T12:00:00Z', action: 'created' }
|
||||
];
|
||||
|
||||
service.getActivityLogs('1').subscribe(logs => {
|
||||
expect(logs.length).toBe(1);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/integrations/1/activity');
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(mockLogs);
|
||||
});
|
||||
const req = httpMock.expectOne('/api/v1/integrations/providers');
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush([
|
||||
{ name: 'harbor', type: IntegrationType.Registry, provider: IntegrationProvider.Harbor },
|
||||
{ name: 'github-app', type: IntegrationType.Scm, provider: IntegrationProvider.GitHubApp },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,21 +3,18 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import {
|
||||
Integration,
|
||||
IntegrationListResponse,
|
||||
CreateIntegrationRequest,
|
||||
UpdateIntegrationRequest,
|
||||
PauseIntegrationRequest,
|
||||
TestConnectionResponse,
|
||||
Integration,
|
||||
IntegrationHealthResponse,
|
||||
IntegrationType,
|
||||
IntegrationListResponse,
|
||||
IntegrationProvider,
|
||||
IntegrationStatus,
|
||||
IntegrationType,
|
||||
SupportedProviderInfo,
|
||||
TestConnectionResponse,
|
||||
UpdateIntegrationRequest,
|
||||
} from './integration.models';
|
||||
|
||||
/**
|
||||
* Service for interacting with the Integration Catalog API.
|
||||
* Sprint: SPRINT_20251229_011_FE_integration_hub_ui
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -25,13 +22,10 @@ export class IntegrationService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = `${environment.apiBaseUrl}/v1/integrations`;
|
||||
|
||||
/**
|
||||
* List integrations with filtering and pagination.
|
||||
*/
|
||||
list(params: {
|
||||
type?: IntegrationType;
|
||||
provider?: IntegrationProvider;
|
||||
status?: IntegrationStatus;
|
||||
environment?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
@@ -41,12 +35,12 @@ export class IntegrationService {
|
||||
if (params.type !== undefined) {
|
||||
httpParams = httpParams.set('type', params.type.toString());
|
||||
}
|
||||
if (params.provider !== undefined) {
|
||||
httpParams = httpParams.set('provider', params.provider.toString());
|
||||
}
|
||||
if (params.status !== undefined) {
|
||||
httpParams = httpParams.set('status', params.status.toString());
|
||||
}
|
||||
if (params.environment) {
|
||||
httpParams = httpParams.set('environment', params.environment);
|
||||
}
|
||||
if (params.search) {
|
||||
httpParams = httpParams.set('search', params.search);
|
||||
}
|
||||
@@ -60,66 +54,31 @@ export class IntegrationService {
|
||||
return this.http.get<IntegrationListResponse>(this.baseUrl, { params: httpParams });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an integration by ID.
|
||||
*/
|
||||
get(integrationId: string): Observable<Integration> {
|
||||
return this.http.get<Integration>(`${this.baseUrl}/${integrationId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new integration.
|
||||
*/
|
||||
create(request: CreateIntegrationRequest): Observable<Integration> {
|
||||
return this.http.post<Integration>(this.baseUrl, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing integration.
|
||||
*/
|
||||
update(integrationId: string, request: UpdateIntegrationRequest): Observable<Integration> {
|
||||
return this.http.put<Integration>(`${this.baseUrl}/${integrationId}`, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an integration.
|
||||
*/
|
||||
delete(integrationId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/${integrationId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to an integration.
|
||||
*/
|
||||
testConnection(integrationId: string): Observable<TestConnectionResponse> {
|
||||
return this.http.post<TestConnectionResponse>(`${this.baseUrl}/${integrationId}/test`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause an integration.
|
||||
*/
|
||||
pause(integrationId: string, request: PauseIntegrationRequest): Observable<Integration> {
|
||||
return this.http.post<Integration>(`${this.baseUrl}/${integrationId}/pause`, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a paused integration.
|
||||
*/
|
||||
resume(integrationId: string): Observable<Integration> {
|
||||
return this.http.post<Integration>(`${this.baseUrl}/${integrationId}/resume`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a draft or pending integration.
|
||||
*/
|
||||
activate(integrationId: string): Observable<Integration> {
|
||||
return this.http.post<Integration>(`${this.baseUrl}/${integrationId}/activate`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get health status of an integration.
|
||||
*/
|
||||
getHealth(integrationId: string): Observable<IntegrationHealthResponse> {
|
||||
return this.http.get<IntegrationHealthResponse>(`${this.baseUrl}/${integrationId}/health`);
|
||||
}
|
||||
|
||||
getSupportedProviders(): Observable<SupportedProviderInfo[]> {
|
||||
return this.http.get<SupportedProviderInfo[]>(`${this.baseUrl}/providers`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<!-- Integration Wizard (Sprint: SPRINT_20251229_014) -->
|
||||
<div class="wizard-container">
|
||||
<!-- Stepper Header -->
|
||||
<div class="wizard-stepper">
|
||||
@for (step of steps; track step; let i = $index) {
|
||||
<button
|
||||
@@ -17,90 +15,131 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Wizard Content -->
|
||||
@if (errorMessage()) {
|
||||
<div class="check-item status-error">
|
||||
<span class="check-status">XX</span>
|
||||
<div class="check-info">
|
||||
<span class="check-name">Create failed</span>
|
||||
<span class="check-message">{{ errorMessage() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="wizard-content">
|
||||
<!-- Provider Step -->
|
||||
@if (currentStep() === 'provider') {
|
||||
<div class="step-content">
|
||||
<h2>Select {{ getTypeLabel() }} Provider</h2>
|
||||
<p class="step-description">Choose the {{ getTypeLabel().toLowerCase() }} provider you want to integrate.</p>
|
||||
<p class="step-description">Choose from the connector plugins that are actually installed in this environment.</p>
|
||||
|
||||
<div class="provider-grid">
|
||||
@for (provider of providers(); track provider.id) {
|
||||
<button
|
||||
class="provider-card"
|
||||
[class.selected]="draft().provider === provider.id"
|
||||
(click)="selectProvider(provider.id)"
|
||||
>
|
||||
<span class="provider-icon">{{ provider.icon }}</span>
|
||||
<span class="provider-name">{{ provider.name }}</span>
|
||||
<span class="provider-desc">{{ provider.description }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (supportedProviders().length === 0) {
|
||||
<div class="check-item status-warning">
|
||||
<span class="check-status">!!</span>
|
||||
<div class="check-info">
|
||||
<span class="check-name">No supported providers</span>
|
||||
<span class="check-message">This category has no installed connector plugins yet.</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="provider-grid">
|
||||
@for (provider of supportedProviders(); track provider.provider) {
|
||||
<button
|
||||
class="provider-card"
|
||||
[class.selected]="draft().provider === provider.provider"
|
||||
type="button"
|
||||
(click)="selectProvider(provider.provider)"
|
||||
>
|
||||
<span class="provider-icon">{{ provider.icon }}</span>
|
||||
<span class="provider-name">{{ provider.name }}</span>
|
||||
<span class="provider-desc">{{ provider.description }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Auth Step -->
|
||||
@if (currentStep() === 'auth') {
|
||||
<div class="step-content">
|
||||
<h2>Configure Authentication</h2>
|
||||
<p class="step-description">Set up authentication for {{ selectedProvider()?.name }}.</p>
|
||||
<h2>Connection & Credentials</h2>
|
||||
<p class="step-description">StellaOps stores only AuthRef URIs here. Keep the actual secret in your vault.</p>
|
||||
|
||||
<div class="auth-methods">
|
||||
@for (method of authMethods(); track method.id) {
|
||||
<div
|
||||
class="auth-method-card"
|
||||
[class.selected]="draft().authMethod === method.id"
|
||||
(click)="selectAuthMethod(method.id)"
|
||||
>
|
||||
<div class="auth-method-header">
|
||||
<input
|
||||
type="radio"
|
||||
[checked]="draft().authMethod === method.id"
|
||||
[id]="'auth-' + method.id"
|
||||
/>
|
||||
<label [for]="'auth-' + method.id">{{ method.name }}</label>
|
||||
</div>
|
||||
<p class="auth-method-desc">{{ method.description }}</p>
|
||||
|
||||
@if (draft().authMethod === method.id) {
|
||||
<div class="auth-fields">
|
||||
@for (field of method.fields; track field.id) {
|
||||
<div class="form-field">
|
||||
<label [for]="'field-' + field.id">
|
||||
{{ field.name }}
|
||||
@if (field.required) {
|
||||
<span class="required">*</span>
|
||||
}
|
||||
</label>
|
||||
@if (field.type === 'text' || field.type === 'password') {
|
||||
<input
|
||||
[type]="field.type"
|
||||
[id]="'field-' + field.id"
|
||||
[placeholder]="field.placeholder || ''"
|
||||
[value]="draft().authValues[field.id] || ''"
|
||||
(input)="updateAuthValue(field.id, $any($event.target).value)"
|
||||
/>
|
||||
}
|
||||
@if (field.hint) {
|
||||
<span class="field-hint">{{ field.hint }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (selectedProvider(); as provider) {
|
||||
<div class="auth-method-card selected">
|
||||
<div class="form-field">
|
||||
<label for="endpoint">
|
||||
Endpoint
|
||||
<span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="endpoint"
|
||||
type="text"
|
||||
[value]="draft().endpoint"
|
||||
[placeholder]="provider.defaultEndpoint"
|
||||
(input)="updateEndpoint($any($event.target).value)"
|
||||
/>
|
||||
<span class="field-hint">{{ provider.endpointHint }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="authRefUri">
|
||||
AuthRef URI
|
||||
<span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="authRefUri"
|
||||
type="text"
|
||||
[value]="draft().authRefUri"
|
||||
placeholder="authref://vault/path#secret"
|
||||
(input)="updateAuthRefUri($any($event.target).value)"
|
||||
/>
|
||||
<span class="field-hint">{{ provider.authRefHint }}</span>
|
||||
</div>
|
||||
|
||||
@if (provider.organizationLabel) {
|
||||
<div class="form-field">
|
||||
<label for="organizationId">{{ provider.organizationLabel }}</label>
|
||||
<input
|
||||
id="organizationId"
|
||||
type="text"
|
||||
[value]="draft().organizationId"
|
||||
placeholder="team-platform"
|
||||
(input)="updateOrganizationId($any($event.target).value)"
|
||||
/>
|
||||
@if (provider.organizationHint) {
|
||||
<span class="field-hint">{{ provider.organizationHint }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (field of provider.configFields; track field.id) {
|
||||
<div class="form-field">
|
||||
<label [for]="'config-' + field.id">
|
||||
{{ field.label }}
|
||||
@if (field.required) {
|
||||
<span class="required">*</span>
|
||||
}
|
||||
</label>
|
||||
<input
|
||||
[id]="'config-' + field.id"
|
||||
type="text"
|
||||
[value]="draft().extendedConfig[field.id] || ''"
|
||||
[placeholder]="field.placeholder || ''"
|
||||
(input)="updateConfigField(field.id, $any($event.target).value)"
|
||||
/>
|
||||
@if (field.hint) {
|
||||
<span class="field-hint">{{ field.hint }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Scope Step -->
|
||||
@if (currentStep() === 'scope') {
|
||||
<div class="step-content">
|
||||
<h2>Define Scope</h2>
|
||||
<p class="step-description">Specify which resources to include in this integration.</p>
|
||||
<h2>Discovery Scope</h2>
|
||||
<p class="step-description">Define which repositories, namespaces, or tag patterns StellaOps should use for this connector.</p>
|
||||
|
||||
<div class="scope-form">
|
||||
@if (integrationType() === 'registry' || integrationType() === 'scm') {
|
||||
@@ -108,11 +147,10 @@
|
||||
<label for="repositories">Repositories</label>
|
||||
<textarea
|
||||
id="repositories"
|
||||
placeholder="One repository per line e.g., owner/repo"
|
||||
rows="4"
|
||||
(input)="parseScopeInput('repositories', $any($event.target).value)"
|
||||
>{{ (draft().scope.repositories || []).join('\n') }}</textarea>
|
||||
<span class="field-hint">Leave empty to include all accessible repositories.</span>
|
||||
placeholder="One repository per line example/api platform/web"
|
||||
(input)="parseListInput('repositories', $any($event.target).value)"
|
||||
>{{ draft().repositories.join('\n') }}</textarea>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -121,45 +159,44 @@
|
||||
<label for="branches">Branch Patterns</label>
|
||||
<textarea
|
||||
id="branches"
|
||||
placeholder="Branch patterns (glob supported) e.g., main, release/*"
|
||||
rows="3"
|
||||
(input)="parseScopeInput('branches', $any($event.target).value)"
|
||||
>{{ (draft().scope.branches || []).join('\n') }}</textarea>
|
||||
placeholder="main release/*"
|
||||
(input)="parseListInput('branches', $any($event.target).value)"
|
||||
>{{ draft().branches.join('\n') }}</textarea>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (integrationType() === 'registry') {
|
||||
<div class="form-field">
|
||||
<label for="namespaces">Namespaces / Projects</label>
|
||||
<textarea
|
||||
id="namespaces"
|
||||
rows="3"
|
||||
placeholder="platform customer-facing"
|
||||
(input)="parseListInput('namespaces', $any($event.target).value)"
|
||||
>{{ draft().namespaces.join('\n') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="tagPatterns">Tag Patterns</label>
|
||||
<textarea
|
||||
id="tagPatterns"
|
||||
placeholder="Tag patterns (glob supported) e.g., v*, latest, release-*"
|
||||
rows="3"
|
||||
(input)="parseScopeInput('tagPatterns', $any($event.target).value)"
|
||||
>{{ (draft().scope.tagPatterns || []).join('\n') }}</textarea>
|
||||
placeholder="latest release-*"
|
||||
(input)="parseListInput('tagPatterns', $any($event.target).value)"
|
||||
>{{ draft().tagPatterns.join('\n') }}</textarea>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (integrationType() === 'host') {
|
||||
<div class="form-field">
|
||||
<label for="namespaces">Namespaces</label>
|
||||
<textarea
|
||||
id="namespaces"
|
||||
placeholder="Kubernetes namespaces (leave empty for all) e.g., default, production"
|
||||
rows="3"
|
||||
(input)="parseScopeInput('namespaces', $any($event.target).value)"
|
||||
>{{ (draft().scope.namespaces || []).join('\n') }}</textarea>
|
||||
</div>
|
||||
}
|
||||
<span class="field-hint">At least one owner, repository, namespace, branch, or tag scope is required before creation.</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Schedule Step -->
|
||||
@if (currentStep() === 'schedule') {
|
||||
<div class="step-content">
|
||||
<h2>Configure Schedule</h2>
|
||||
<p class="step-description">Set up how often scans should run.</p>
|
||||
<h2>Check Schedule</h2>
|
||||
<p class="step-description">Define how StellaOps should revisit this connector after onboarding.</p>
|
||||
|
||||
<div class="schedule-options">
|
||||
@for (option of scheduleOptions; track option.value) {
|
||||
@@ -168,12 +205,8 @@
|
||||
[class.selected]="draft().schedule.type === option.value"
|
||||
(click)="updateSchedule('type', $any(option.value))"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
[checked]="draft().schedule.type === option.value"
|
||||
[id]="'schedule-' + option.value"
|
||||
/>
|
||||
<label [for]="'schedule-' + option.value">
|
||||
<input type="radio" [checked]="draft().schedule.type === option.value" />
|
||||
<label>
|
||||
<span class="schedule-label">{{ option.label }}</span>
|
||||
<span class="schedule-desc">{{ option.description }}</span>
|
||||
</label>
|
||||
@@ -183,14 +216,14 @@
|
||||
|
||||
@if (draft().schedule.type === 'interval') {
|
||||
<div class="form-field">
|
||||
<label for="interval">Scan Interval</label>
|
||||
<label for="interval">Check Interval</label>
|
||||
<select
|
||||
id="interval"
|
||||
[value]="draft().schedule.intervalMinutes || 60"
|
||||
(change)="updateSchedule('intervalMinutes', +$any($event.target).value)"
|
||||
>
|
||||
@for (opt of intervalOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{ opt.label }}</option>
|
||||
@for (option of intervalOptions; track option.value) {
|
||||
<option [value]="option.value">{{ option.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@@ -198,55 +231,32 @@
|
||||
|
||||
@if (draft().schedule.type === 'cron') {
|
||||
<div class="form-field">
|
||||
<label for="cron">Cron Expression</label>
|
||||
<label for="cronExpression">Cron Expression</label>
|
||||
<input
|
||||
id="cronExpression"
|
||||
type="text"
|
||||
id="cron"
|
||||
placeholder="0 0 * * *"
|
||||
[value]="draft().schedule.cronExpression || ''"
|
||||
placeholder="0 */6 * * *"
|
||||
(input)="updateSchedule('cronExpression', $any($event.target).value)"
|
||||
/>
|
||||
<span class="field-hint">Standard cron syntax (minute hour day month weekday)</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (integrationType() === 'registry' || integrationType() === 'scm') {
|
||||
<div class="webhook-toggle">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="draft().webhookEnabled"
|
||||
(change)="toggleWebhook()"
|
||||
/>
|
||||
<span>Enable webhook for real-time triggers</span>
|
||||
<input type="checkbox" [checked]="draft().webhookEnabled" (change)="toggleWebhook()" />
|
||||
<span>Record webhook intent in connector metadata</span>
|
||||
</label>
|
||||
|
||||
@if (draft().webhookEnabled && draft().webhookSecret) {
|
||||
<div class="webhook-secret">
|
||||
<label>Webhook Secret</label>
|
||||
<div class="secret-display">
|
||||
<code>{{ draft().webhookSecret }}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn"
|
||||
(click)="copyToClipboard(draft().webhookSecret!)"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<span class="field-hint">Use this secret to configure the webhook in {{ selectedProvider()?.name }}.</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Preflight Step -->
|
||||
@if (currentStep() === 'preflight') {
|
||||
<div class="step-content">
|
||||
<h2>Preflight Checks</h2>
|
||||
<p class="step-description">Verifying your integration configuration.</p>
|
||||
<p class="step-description">Validate the draft against the connector contract before creation.</p>
|
||||
|
||||
<div class="preflight-checks">
|
||||
@for (check of preflightChecks(); track check.id) {
|
||||
@@ -272,87 +282,48 @@
|
||||
</div>
|
||||
|
||||
@if (!preflightRunning() && preflightChecks().length > 0) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
(click)="runPreflightChecks()"
|
||||
>
|
||||
Re-run Checks
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (preflightRunning()) {
|
||||
<p class="running-message">Running preflight checks...</p>
|
||||
<button type="button" class="btn btn-secondary" (click)="runPreflightChecks()">Re-run Checks</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Review Step -->
|
||||
@if (currentStep() === 'review') {
|
||||
<div class="step-content">
|
||||
<h2>Review & Create</h2>
|
||||
<p class="step-description">Review your integration configuration before creating.</p>
|
||||
<p class="step-description">Review the canonical connector request before StellaOps persists it.</p>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="name">Integration Name</label>
|
||||
<label for="integrationName">
|
||||
Integration Name
|
||||
<span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="integrationName"
|
||||
type="text"
|
||||
id="name"
|
||||
[value]="draft().name"
|
||||
(input)="updateName($any($event.target).value)"
|
||||
placeholder="Enter a name for this integration"
|
||||
placeholder="Production Harbor Registry"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="review-summary">
|
||||
<div class="summary-section">
|
||||
<h3>Provider</h3>
|
||||
<p>{{ selectedProvider()?.name }} ({{ selectedProvider()?.type }})</p>
|
||||
</div>
|
||||
|
||||
<div class="summary-section">
|
||||
<h3>Authentication</h3>
|
||||
<p>{{ selectedAuthMethod()?.name }}</p>
|
||||
</div>
|
||||
|
||||
<div class="summary-section">
|
||||
<h3>Schedule</h3>
|
||||
<p>{{ draft().schedule.type | titlecase }}
|
||||
@if (draft().schedule.type === 'interval') {
|
||||
- Every {{ draft().schedule.intervalMinutes }} minutes
|
||||
}
|
||||
@if (draft().schedule.type === 'cron') {
|
||||
- {{ draft().schedule.cronExpression }}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (draft().webhookEnabled) {
|
||||
@if (selectedProvider(); as provider) {
|
||||
<div class="review-summary">
|
||||
<div class="summary-section">
|
||||
<h3>Webhook</h3>
|
||||
<p>Enabled</p>
|
||||
<h3>Provider</h3>
|
||||
<p>{{ provider.name }}</p>
|
||||
</div>
|
||||
<div class="summary-section">
|
||||
<h3>Endpoint</h3>
|
||||
<p>{{ draft().endpoint }}</p>
|
||||
</div>
|
||||
<div class="summary-section">
|
||||
<h3>AuthRef URI</h3>
|
||||
<p>{{ draft().authRefUri }}</p>
|
||||
</div>
|
||||
<div class="summary-section">
|
||||
<h3>Schedule</h3>
|
||||
<p>{{ draft().schedule.type | titlecase }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (deploymentTemplate()) {
|
||||
<div class="deployment-template">
|
||||
<h3>Deployment Template</h3>
|
||||
<p class="template-hint">Copy-safe installer template with placeholder secret values.</p>
|
||||
<pre><code>{{ deploymentTemplate() }}</code></pre>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
(click)="copyToClipboard(deploymentTemplate()!)"
|
||||
>
|
||||
Copy Template
|
||||
</button>
|
||||
|
||||
<ul class="copy-safety-list">
|
||||
@for (item of copySafetyGuidance(); track item) {
|
||||
<li>{{ item }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -381,7 +352,6 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Wizard Footer -->
|
||||
<div class="wizard-footer">
|
||||
<button type="button" class="btn btn-text" (click)="onCancel()">Cancel</button>
|
||||
|
||||
@@ -391,22 +361,10 @@
|
||||
}
|
||||
|
||||
@if (currentStep() !== 'review') {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
[disabled]="!canGoNext()"
|
||||
(click)="goNext()"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" [disabled]="!canGoNext()" (click)="goNext()">Next</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
[disabled]="!canGoNext()"
|
||||
(click)="onSubmit()"
|
||||
>
|
||||
Create Integration
|
||||
<button type="button" class="btn btn-primary" [disabled]="!canGoNext() || creating()" (click)="onSubmit()">
|
||||
{{ creating() ? 'Creating...' : 'Create Integration' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,264 +1,76 @@
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { signal } from '@angular/core';
|
||||
import { IntegrationWizardComponent } from './integration-wizard.component';
|
||||
import {
|
||||
IntegrationType,
|
||||
IntegrationProvider,
|
||||
WizardStep,
|
||||
REGISTRY_PROVIDERS,
|
||||
SCM_PROVIDERS,
|
||||
CI_PROVIDERS,
|
||||
HOST_PROVIDERS,
|
||||
} from './models/integration.models';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { IntegrationProvider, IntegrationType } from '../integration-hub/integration.models';
|
||||
import { IntegrationWizardComponent } from './integration-wizard.component';
|
||||
import { resolveSupportedProviders } from './models/integration.models';
|
||||
|
||||
/**
|
||||
* Unit tests for Integration Wizard Component
|
||||
* @sprint SPRINT_20251229_014_FE_integration_wizards
|
||||
*/
|
||||
describe('IntegrationWizardComponent', () => {
|
||||
let component: IntegrationWizardComponent;
|
||||
let fixture: ComponentFixture<IntegrationWizardComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IntegrationWizardComponent, CommonModule, FormsModule],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
function createComponent(integrationType: IntegrationType = 'registry'): void {
|
||||
fixture = TestBed.createComponent(IntegrationWizardComponent);
|
||||
component = fixture.componentInstance;
|
||||
// Use ComponentRef to set required inputs
|
||||
fixture.componentRef.setInput('integrationType', integrationType);
|
||||
fixture.detectChanges();
|
||||
function createComponent() {
|
||||
component = TestBed.runInInjectionContext(() => new IntegrationWizardComponent());
|
||||
(component as any).integrationType = () => 'scm';
|
||||
(component as any).supportedProviders = () => resolveSupportedProviders('scm', [
|
||||
{ name: 'github-app', type: IntegrationType.Scm, provider: IntegrationProvider.GitHubApp },
|
||||
]);
|
||||
component.draft.update((draft) => ({ ...draft, type: 'scm' }));
|
||||
}
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should create the component', () => {
|
||||
createComponent();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
it('selects a supported provider and seeds its default endpoint', () => {
|
||||
createComponent();
|
||||
component.selectProvider(IntegrationProvider.GitHubApp);
|
||||
|
||||
it('should initialize with provider step', () => {
|
||||
createComponent();
|
||||
expect(component.currentStep()).toBe('provider');
|
||||
});
|
||||
|
||||
it('should have 6 wizard steps', () => {
|
||||
createComponent();
|
||||
expect(component.steps).toEqual(['provider', 'auth', 'scope', 'schedule', 'preflight', 'review']);
|
||||
});
|
||||
|
||||
it('should initialize with empty draft', () => {
|
||||
createComponent();
|
||||
const draft = component.draft();
|
||||
expect(draft.name).toBe('');
|
||||
expect(draft.provider).toBeNull();
|
||||
expect(draft.authMethod).toBeNull();
|
||||
});
|
||||
expect(component.draft().provider).toBe(IntegrationProvider.GitHubApp);
|
||||
expect(component.draft().endpoint).toBe('https://github.com');
|
||||
expect(component.draft().name).toContain('GitHub App');
|
||||
});
|
||||
|
||||
describe('Provider Selection by Integration Type', () => {
|
||||
it('should show registry providers for registry type', () => {
|
||||
createComponent('registry');
|
||||
expect(component.providers()).toEqual(REGISTRY_PROVIDERS);
|
||||
});
|
||||
it('requires AuthRef URI and provider metadata before leaving the connection step', () => {
|
||||
createComponent();
|
||||
component.selectProvider(IntegrationProvider.GitHubApp);
|
||||
component.currentStep.set('auth');
|
||||
|
||||
it('should show SCM providers for scm type', () => {
|
||||
createComponent('scm');
|
||||
expect(component.providers()).toEqual(SCM_PROVIDERS);
|
||||
});
|
||||
expect(component.canGoNext()).toBeFalse();
|
||||
|
||||
it('should show CI providers for ci type', () => {
|
||||
createComponent('ci');
|
||||
expect(component.providers()).toEqual(CI_PROVIDERS);
|
||||
});
|
||||
component.updateAuthRefUri('authref://vault/github#app');
|
||||
component.updateConfigField('appId', '12345');
|
||||
component.updateConfigField('installationId', '67890');
|
||||
|
||||
it('should show host providers for host type', () => {
|
||||
createComponent('host');
|
||||
expect(component.providers()).toEqual(HOST_PROVIDERS);
|
||||
});
|
||||
expect(component.canGoNext()).toBeTrue();
|
||||
});
|
||||
|
||||
describe('Step Navigation', () => {
|
||||
it('should compute current step index correctly', () => {
|
||||
createComponent();
|
||||
expect(component.currentStepIndex()).toBe(0);
|
||||
});
|
||||
it('emits a canonical create request instead of a UI-only draft', () => {
|
||||
createComponent();
|
||||
component.selectProvider(IntegrationProvider.GitHubApp);
|
||||
const emitSpy = jasmine.createSpy('emit');
|
||||
spyOn(component.create, 'emit').and.callFake(emitSpy);
|
||||
|
||||
it('should prevent navigation when canGoNext is false', () => {
|
||||
createComponent();
|
||||
// Without a provider selected, canGoNext should be false
|
||||
expect(component.canGoNext()).toBe(false);
|
||||
});
|
||||
component.updateAuthRefUri('authref://vault/github#app');
|
||||
component.updateConfigField('appId', '12345');
|
||||
component.updateConfigField('installationId', '67890');
|
||||
component.updateOrganizationId('platform');
|
||||
component.parseListInput('repositories', 'platform/api');
|
||||
component.updateSchedule('type', 'interval');
|
||||
component.updateSchedule('intervalMinutes', 60);
|
||||
component.updateName('GitHub App Platform');
|
||||
component.currentStep.set('review');
|
||||
|
||||
it('should allow navigation after provider selection', () => {
|
||||
createComponent();
|
||||
const draft = component.draft();
|
||||
component.draft.set({
|
||||
...draft,
|
||||
provider: 'docker-hub',
|
||||
type: 'registry',
|
||||
});
|
||||
// After setting provider, should enable next
|
||||
expect(component.draft().provider).toBe('docker-hub');
|
||||
});
|
||||
});
|
||||
component.onSubmit();
|
||||
|
||||
describe('Provider Selection', () => {
|
||||
it('should update draft when provider is selected', () => {
|
||||
createComponent('registry');
|
||||
const provider = REGISTRY_PROVIDERS[0];
|
||||
component.selectProvider(provider.id);
|
||||
expect(component.draft().provider).toBe(provider.id);
|
||||
});
|
||||
|
||||
it('should return selected provider info', () => {
|
||||
createComponent('registry');
|
||||
const provider = REGISTRY_PROVIDERS[0];
|
||||
component.selectProvider(provider.id);
|
||||
expect(component.selectedProvider()?.id).toBe(provider.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth Method Selection', () => {
|
||||
it('should have auth methods for registry type', () => {
|
||||
createComponent('registry');
|
||||
const authMethods = component.authMethods();
|
||||
expect(authMethods.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should update draft when auth method is selected', () => {
|
||||
createComponent('registry');
|
||||
const authMethods = component.authMethods();
|
||||
if (authMethods.length > 0) {
|
||||
component.selectAuthMethod(authMethods[0].id);
|
||||
expect(component.draft().authMethod).toBe(authMethods[0].id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Draft Management', () => {
|
||||
it('should update draft name', () => {
|
||||
createComponent();
|
||||
component.updateName('My Integration');
|
||||
expect(component.draft().name).toBe('My Integration');
|
||||
});
|
||||
|
||||
it('should add tags to draft', () => {
|
||||
createComponent();
|
||||
component.newTag.set('production');
|
||||
component.addTag();
|
||||
expect(component.draft().tags).toContain('production');
|
||||
});
|
||||
|
||||
it('should not add duplicate tags', () => {
|
||||
createComponent();
|
||||
component.newTag.set('production');
|
||||
component.addTag();
|
||||
component.newTag.set('production');
|
||||
component.addTag();
|
||||
expect(component.draft().tags.filter(t => t === 'production').length).toBe(1);
|
||||
});
|
||||
|
||||
it('should remove tags from draft', () => {
|
||||
createComponent();
|
||||
component.newTag.set('production');
|
||||
component.addTag();
|
||||
expect(component.draft().tags).toContain('production');
|
||||
component.removeTag('production');
|
||||
expect(component.draft().tags).not.toContain('production');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Schedule Configuration', () => {
|
||||
it('should default to manual schedule', () => {
|
||||
createComponent();
|
||||
expect(component.draft().schedule.type).toBe('manual');
|
||||
});
|
||||
|
||||
it('should allow setting cron schedule type', () => {
|
||||
createComponent();
|
||||
component.updateSchedule('type', 'cron');
|
||||
expect(component.draft().schedule.type).toBe('cron');
|
||||
});
|
||||
|
||||
it('should allow setting cron expression', () => {
|
||||
createComponent();
|
||||
component.updateSchedule('cronExpression', '0 0 * * *');
|
||||
expect(component.draft().schedule.cronExpression).toBe('0 0 * * *');
|
||||
});
|
||||
|
||||
it('should allow setting interval schedule', () => {
|
||||
createComponent();
|
||||
component.updateSchedule('type', 'interval');
|
||||
component.updateSchedule('intervalMinutes', 60);
|
||||
expect(component.draft().schedule.type).toBe('interval');
|
||||
expect(component.draft().schedule.intervalMinutes).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhook Configuration', () => {
|
||||
it('should default to webhook disabled', () => {
|
||||
createComponent();
|
||||
expect(component.draft().webhookEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle webhook setting', () => {
|
||||
createComponent();
|
||||
component.toggleWebhook();
|
||||
expect(component.draft().webhookEnabled).toBe(true);
|
||||
component.toggleWebhook();
|
||||
expect(component.draft().webhookEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Preflight Checks', () => {
|
||||
it('should initialize with empty preflight checks', () => {
|
||||
createComponent();
|
||||
expect(component.preflightChecks()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should track preflight running state', () => {
|
||||
createComponent();
|
||||
expect(component.preflightRunning()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cancel and Create Outputs', () => {
|
||||
it('should emit cancel event', () => {
|
||||
createComponent();
|
||||
const cancelSpy = jasmine.createSpy('cancel');
|
||||
component.cancel.subscribe(cancelSpy);
|
||||
component.onCancel();
|
||||
expect(cancelSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit create event with draft via onSubmit', () => {
|
||||
createComponent();
|
||||
const createSpy = jasmine.createSpy('create');
|
||||
component.create.subscribe(createSpy);
|
||||
|
||||
// Setup a complete draft
|
||||
component.draft.set({
|
||||
name: 'Test Integration',
|
||||
provider: 'docker-hub',
|
||||
type: 'registry',
|
||||
authMethod: 'token',
|
||||
authValues: { token: 'test-token' },
|
||||
scope: { repositories: ['repo1'] },
|
||||
schedule: { type: 'manual' },
|
||||
webhookEnabled: false,
|
||||
tags: ['test'],
|
||||
});
|
||||
|
||||
// Navigate to review step so canGoNext is true
|
||||
component.currentStep.set('review');
|
||||
component.onSubmit();
|
||||
expect(createSpy).toHaveBeenCalledWith(component.draft());
|
||||
});
|
||||
expect(emitSpy).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
name: 'GitHub App Platform',
|
||||
type: IntegrationType.Scm,
|
||||
provider: IntegrationProvider.GitHubApp,
|
||||
endpoint: 'https://github.com',
|
||||
authRefUri: 'authref://vault/github#app',
|
||||
organizationId: 'platform',
|
||||
extendedConfig: jasmine.objectContaining({
|
||||
appId: '12345',
|
||||
installationId: '67890',
|
||||
repositories: ['platform/api'],
|
||||
scheduleType: 'interval',
|
||||
intervalMinutes: 60,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,30 +3,26 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { CreateIntegrationRequest } from '../integration-hub/integration.models';
|
||||
import {
|
||||
buildCreateIntegrationRequest,
|
||||
IntegrationDraft,
|
||||
IntegrationProvider,
|
||||
IntegrationProviderInfo,
|
||||
IntegrationType,
|
||||
WizardStep,
|
||||
IntegrationOnboardingType,
|
||||
IntegrationProviderDefinition,
|
||||
intervalOptions,
|
||||
PreflightCheck,
|
||||
AuthMethod,
|
||||
REGISTRY_PROVIDERS,
|
||||
SCM_PROVIDERS,
|
||||
CI_PROVIDERS,
|
||||
HOST_PROVIDERS,
|
||||
AUTH_METHODS,
|
||||
resolveProviderDefinition,
|
||||
scheduleOptions,
|
||||
WizardStep,
|
||||
} from './models/integration.models';
|
||||
|
||||
/**
|
||||
* Integration onboarding wizard component (Sprint: SPRINT_20251229_014)
|
||||
* Provides guided setup for registry, SCM, CI, and host integrations.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-integration-wizard',
|
||||
standalone: true,
|
||||
@@ -36,74 +32,55 @@ import {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class IntegrationWizardComponent {
|
||||
/** Integration type to create */
|
||||
readonly integrationType = input.required<IntegrationType>();
|
||||
readonly integrationType = input.required<IntegrationOnboardingType>();
|
||||
readonly supportedProviders = input<readonly IntegrationProviderDefinition[]>([]);
|
||||
readonly creating = input(false);
|
||||
readonly errorMessage = input<string | null>(null);
|
||||
|
||||
/** Pre-selected provider */
|
||||
readonly preselectedProvider = input<IntegrationProvider>();
|
||||
|
||||
/** Emits when wizard is cancelled */
|
||||
readonly cancel = output<void>();
|
||||
|
||||
/** Emits when integration is created */
|
||||
readonly create = output<IntegrationDraft>();
|
||||
readonly create = output<CreateIntegrationRequest>();
|
||||
|
||||
readonly steps: WizardStep[] = ['provider', 'auth', 'scope', 'schedule', 'preflight', 'review'];
|
||||
readonly currentStep = signal<WizardStep>('provider');
|
||||
readonly preflightChecks = signal<PreflightCheck[]>([]);
|
||||
readonly preflightRunning = signal(false);
|
||||
readonly newTag = signal('');
|
||||
|
||||
readonly draft = signal<IntegrationDraft>({
|
||||
name: '',
|
||||
provider: null,
|
||||
type: null,
|
||||
authMethod: null,
|
||||
authValues: {},
|
||||
scope: {},
|
||||
endpoint: '',
|
||||
authRefUri: '',
|
||||
organizationId: '',
|
||||
repositories: [],
|
||||
branches: [],
|
||||
namespaces: [],
|
||||
tagPatterns: [],
|
||||
schedule: { type: 'manual' },
|
||||
webhookEnabled: false,
|
||||
tags: [],
|
||||
extendedConfig: {},
|
||||
});
|
||||
|
||||
readonly preflightChecks = signal<PreflightCheck[]>([]);
|
||||
readonly preflightRunning = signal(false);
|
||||
readonly newTag = signal('');
|
||||
|
||||
readonly currentStepIndex = computed(() => this.steps.indexOf(this.currentStep()));
|
||||
readonly selectedProvider = computed(() => resolveProviderDefinition(this.draft().provider));
|
||||
readonly deploymentTemplate = computed(() => null);
|
||||
readonly copySafetyGuidance = computed(() => [
|
||||
'Only AuthRef URIs are stored. Keep the underlying secret in a vault.',
|
||||
'Non-secret provider fields such as App ID or Installation ID are stored as connector metadata.',
|
||||
'Use environment-specific endpoints so prod, stage, and lab connectors remain explicit.',
|
||||
]);
|
||||
readonly scheduleOptions = scheduleOptions;
|
||||
readonly intervalOptions = intervalOptions;
|
||||
|
||||
readonly providers = computed((): IntegrationProviderInfo[] => {
|
||||
switch (this.integrationType()) {
|
||||
case 'registry': return REGISTRY_PROVIDERS;
|
||||
case 'scm': return SCM_PROVIDERS;
|
||||
case 'ci': return CI_PROVIDERS;
|
||||
case 'host': return HOST_PROVIDERS;
|
||||
default: return [];
|
||||
}
|
||||
});
|
||||
|
||||
readonly authMethods = computed((): AuthMethod[] => {
|
||||
return AUTH_METHODS[this.integrationType()] || [];
|
||||
});
|
||||
|
||||
readonly selectedProvider = computed(() => {
|
||||
const providerId = this.draft().provider;
|
||||
return this.providers().find(p => p.id === providerId) || null;
|
||||
});
|
||||
|
||||
readonly selectedAuthMethod = computed(() => {
|
||||
const methodId = this.draft().authMethod;
|
||||
return this.authMethods().find(m => m.id === methodId) || null;
|
||||
});
|
||||
readonly deploymentTemplate = computed(() => this.buildDeploymentTemplate());
|
||||
readonly copySafetyGuidance = computed(() => this.getCopySafetyGuidance());
|
||||
|
||||
readonly canGoBack = computed(() => this.currentStepIndex() > 0);
|
||||
readonly canGoNext = computed(() => {
|
||||
const step = this.currentStep();
|
||||
const d = this.draft();
|
||||
|
||||
switch (step) {
|
||||
switch (this.currentStep()) {
|
||||
case 'provider':
|
||||
return d.provider !== null;
|
||||
return this.draft().provider !== null;
|
||||
case 'auth':
|
||||
return this.isAuthValid();
|
||||
return this.isConnectionValid();
|
||||
case 'scope':
|
||||
return this.isScopeValid();
|
||||
case 'schedule':
|
||||
@@ -111,169 +88,150 @@ export class IntegrationWizardComponent {
|
||||
case 'preflight':
|
||||
return this.isPreflightPassed();
|
||||
case 'review':
|
||||
return d.name.trim().length > 0;
|
||||
return this.draft().name.trim().length > 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
readonly canGoBack = computed(() => this.currentStepIndex() > 0);
|
||||
private readonly syncTypeAndProviderSelection = effect(() => {
|
||||
const integrationType = this.integrationType();
|
||||
const providers = this.supportedProviders();
|
||||
const currentProvider = this.draft().provider;
|
||||
|
||||
readonly scheduleOptions = [
|
||||
{ value: 'manual', label: 'Manual', description: 'Trigger scans manually or via API' },
|
||||
{ value: 'interval', label: 'Interval', description: 'Run at fixed intervals' },
|
||||
{ value: 'cron', label: 'Cron', description: 'Use cron expression for scheduling' },
|
||||
];
|
||||
this.draft.update((draft) => (
|
||||
draft.type === integrationType
|
||||
? draft
|
||||
: { ...draft, type: integrationType }
|
||||
));
|
||||
|
||||
readonly intervalOptions = [
|
||||
{ value: 15, label: '15 minutes' },
|
||||
{ value: 30, label: '30 minutes' },
|
||||
{ value: 60, label: '1 hour' },
|
||||
{ value: 360, label: '6 hours' },
|
||||
{ value: 720, label: '12 hours' },
|
||||
{ value: 1440, label: '24 hours' },
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
// Apply preselected provider if provided
|
||||
if (this.preselectedProvider()) {
|
||||
this.selectProvider(this.preselectedProvider()!);
|
||||
if (providers.length === 1 && currentProvider === null) {
|
||||
this.selectProvider(providers[0].provider);
|
||||
this.currentStep.set('auth');
|
||||
}
|
||||
});
|
||||
|
||||
// Set type from input
|
||||
this.draft.update(d => ({ ...d, type: this.integrationType() }));
|
||||
}
|
||||
|
||||
selectProvider(providerId: IntegrationProvider): void {
|
||||
const provider = this.providers().find(p => p.id === providerId);
|
||||
if (provider) {
|
||||
this.draft.update(d => ({
|
||||
...d,
|
||||
provider: providerId,
|
||||
name: d.name || `${provider.name} Integration`,
|
||||
}));
|
||||
selectProvider(provider: IntegrationProviderDefinition['provider']): void {
|
||||
const definition = this.supportedProviders().find((item) => item.provider === provider) ?? null;
|
||||
if (definition === null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
selectAuthMethod(methodId: string): void {
|
||||
this.draft.update(d => ({
|
||||
...d,
|
||||
authMethod: methodId,
|
||||
authValues: {},
|
||||
this.draft.update((draft) => ({
|
||||
...draft,
|
||||
provider,
|
||||
type: this.integrationType(),
|
||||
name: draft.name || `${definition.name} ${this.getTypeLabel()} Integration`,
|
||||
endpoint: draft.endpoint || definition.defaultEndpoint,
|
||||
organizationId: draft.organizationId,
|
||||
extendedConfig: this.ensureConfigDefaults(draft.extendedConfig, definition),
|
||||
}));
|
||||
}
|
||||
|
||||
updateAuthValue(fieldId: string, value: string): void {
|
||||
this.draft.update(d => ({
|
||||
...d,
|
||||
authValues: { ...d.authValues, [fieldId]: value },
|
||||
updateName(value: string): void {
|
||||
this.draft.update((draft) => ({ ...draft, name: value }));
|
||||
}
|
||||
|
||||
updateEndpoint(value: string): void {
|
||||
this.draft.update((draft) => ({ ...draft, endpoint: value }));
|
||||
}
|
||||
|
||||
updateAuthRefUri(value: string): void {
|
||||
this.draft.update((draft) => ({ ...draft, authRefUri: value }));
|
||||
}
|
||||
|
||||
updateOrganizationId(value: string): void {
|
||||
this.draft.update((draft) => ({ ...draft, organizationId: value }));
|
||||
}
|
||||
|
||||
updateConfigField(fieldId: string, value: string): void {
|
||||
this.draft.update((draft) => ({
|
||||
...draft,
|
||||
extendedConfig: {
|
||||
...draft.extendedConfig,
|
||||
[fieldId]: value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
updateScope<K extends keyof IntegrationDraft['scope']>(
|
||||
key: K,
|
||||
value: IntegrationDraft['scope'][K]
|
||||
): void {
|
||||
this.draft.update(d => ({
|
||||
...d,
|
||||
scope: { ...d.scope, [key]: value },
|
||||
}));
|
||||
}
|
||||
parseListInput(field: 'repositories' | 'branches' | 'namespaces' | 'tagPatterns', rawValue: string): void {
|
||||
const items = rawValue
|
||||
.split('\n')
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
|
||||
parseScopeInput(field: keyof IntegrationDraft['scope'], rawValue: string): void {
|
||||
const parsed = rawValue.split('\n').filter(v => v.trim());
|
||||
this.updateScope(field, parsed as IntegrationDraft['scope'][typeof field]);
|
||||
this.draft.update((draft) => ({
|
||||
...draft,
|
||||
[field]: items,
|
||||
}));
|
||||
}
|
||||
|
||||
updateSchedule<K extends keyof IntegrationDraft['schedule']>(
|
||||
key: K,
|
||||
value: IntegrationDraft['schedule'][K]
|
||||
value: IntegrationDraft['schedule'][K],
|
||||
): void {
|
||||
this.draft.update(d => ({
|
||||
...d,
|
||||
schedule: { ...d.schedule, [key]: value },
|
||||
this.draft.update((draft) => ({
|
||||
...draft,
|
||||
schedule: { ...draft.schedule, [key]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
updateName(name: string): void {
|
||||
this.draft.update(d => ({ ...d, name }));
|
||||
}
|
||||
|
||||
toggleWebhook(): void {
|
||||
this.draft.update(d => ({
|
||||
...d,
|
||||
webhookEnabled: !d.webhookEnabled,
|
||||
webhookSecret: d.webhookEnabled ? undefined : this.generateWebhookSecret(),
|
||||
this.draft.update((draft) => ({
|
||||
...draft,
|
||||
webhookEnabled: !draft.webhookEnabled,
|
||||
}));
|
||||
}
|
||||
|
||||
addTag(): void {
|
||||
const tag = this.newTag().trim();
|
||||
if (tag && !this.draft().tags.includes(tag)) {
|
||||
this.draft.update(d => ({ ...d, tags: [...d.tags, tag] }));
|
||||
this.newTag.set('');
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(tag: string): void {
|
||||
this.draft.update(d => ({ ...d, tags: d.tags.filter(t => t !== tag) }));
|
||||
}
|
||||
|
||||
onTagInput(event: Event): void {
|
||||
this.newTag.set((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
async runPreflightChecks(): Promise<void> {
|
||||
this.preflightRunning.set(true);
|
||||
const checks: PreflightCheck[] = this.getPreflightChecks();
|
||||
this.preflightChecks.set(checks.map(c => ({ ...c, status: 'pending' })));
|
||||
|
||||
// Deterministic sequential preflight checks.
|
||||
for (let i = 0; i < checks.length; i++) {
|
||||
this.preflightChecks.update(list =>
|
||||
list.map((c, idx) => idx === i ? { ...c, status: 'running' } : c)
|
||||
);
|
||||
|
||||
await this.waitForPreflightTick();
|
||||
const result = this.evaluatePreflightResult(checks[i]);
|
||||
this.preflightChecks.update(list =>
|
||||
list.map((c, idx) => idx === i ? {
|
||||
...c,
|
||||
status: result.status,
|
||||
message: result.message,
|
||||
} : c)
|
||||
);
|
||||
addTag(): void {
|
||||
const tag = this.newTag().trim();
|
||||
if (tag.length === 0 || this.draft().tags.includes(tag)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.preflightRunning.set(false);
|
||||
this.draft.update((draft) => ({ ...draft, tags: [...draft.tags, tag] }));
|
||||
this.newTag.set('');
|
||||
}
|
||||
|
||||
removeTag(tag: string): void {
|
||||
this.draft.update((draft) => ({
|
||||
...draft,
|
||||
tags: draft.tags.filter((item) => item !== tag),
|
||||
}));
|
||||
}
|
||||
|
||||
goNext(): void {
|
||||
if (!this.canGoNext()) return;
|
||||
const idx = this.currentStepIndex();
|
||||
if (idx < this.steps.length - 1) {
|
||||
const nextStep = this.steps[idx + 1];
|
||||
this.currentStep.set(nextStep);
|
||||
if (!this.canGoNext()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-run preflight checks when entering preflight step
|
||||
if (nextStep === 'preflight' && this.preflightChecks().length === 0) {
|
||||
this.runPreflightChecks();
|
||||
}
|
||||
const nextIndex = this.currentStepIndex() + 1;
|
||||
if (nextIndex >= this.steps.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStep = this.steps[nextIndex];
|
||||
this.currentStep.set(nextStep);
|
||||
if (nextStep === 'preflight' && this.preflightChecks().length === 0) {
|
||||
void this.runPreflightChecks();
|
||||
}
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
if (!this.canGoBack()) return;
|
||||
const idx = this.currentStepIndex();
|
||||
if (idx > 0) {
|
||||
this.currentStep.set(this.steps[idx - 1]);
|
||||
if (!this.canGoBack()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentStep.set(this.steps[this.currentStepIndex() - 1]);
|
||||
}
|
||||
|
||||
goToStep(step: WizardStep): void {
|
||||
const targetIdx = this.steps.indexOf(step);
|
||||
if (targetIdx <= this.currentStepIndex()) {
|
||||
const targetIndex = this.steps.indexOf(step);
|
||||
if (targetIndex <= this.currentStepIndex()) {
|
||||
this.currentStep.set(step);
|
||||
}
|
||||
}
|
||||
@@ -283,269 +241,189 @@ export class IntegrationWizardComponent {
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.canGoNext()) {
|
||||
this.create.emit(this.draft());
|
||||
const request = buildCreateIntegrationRequest(this.draft());
|
||||
if (request === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.create.emit(request);
|
||||
}
|
||||
|
||||
async runPreflightChecks(): Promise<void> {
|
||||
const checks = this.getPreflightChecks();
|
||||
this.preflightRunning.set(true);
|
||||
this.preflightChecks.set(checks.map((check) => ({ ...check, status: 'pending' })));
|
||||
|
||||
for (let index = 0; index < checks.length; index += 1) {
|
||||
this.preflightChecks.update((items) =>
|
||||
items.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, status: 'running' } : item),
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
const result = this.evaluatePreflightResult(checks[index]);
|
||||
this.preflightChecks.update((items) =>
|
||||
items.map((item, itemIndex) =>
|
||||
itemIndex === index
|
||||
? { ...item, status: result.status, message: result.message }
|
||||
: item),
|
||||
);
|
||||
}
|
||||
|
||||
this.preflightRunning.set(false);
|
||||
}
|
||||
|
||||
getStepLabel(step: WizardStep): string {
|
||||
switch (step) {
|
||||
case 'provider':
|
||||
return 'Provider';
|
||||
case 'auth':
|
||||
return 'Connection';
|
||||
case 'scope':
|
||||
return 'Scope';
|
||||
case 'schedule':
|
||||
return 'Schedule';
|
||||
case 'preflight':
|
||||
return 'Preflight';
|
||||
case 'review':
|
||||
return 'Review';
|
||||
default:
|
||||
return step;
|
||||
}
|
||||
}
|
||||
|
||||
getTypeLabel(): string {
|
||||
switch (this.integrationType()) {
|
||||
case 'registry':
|
||||
return 'Registry';
|
||||
case 'scm':
|
||||
return 'SCM';
|
||||
case 'ci':
|
||||
return 'CI/CD';
|
||||
case 'host':
|
||||
return 'Host';
|
||||
default:
|
||||
return 'Integration';
|
||||
}
|
||||
}
|
||||
|
||||
copyToClipboard(text: string): void {
|
||||
navigator.clipboard.writeText(text);
|
||||
void navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
private async waitForPreflightTick(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
private evaluatePreflightResult(
|
||||
check: PreflightCheck
|
||||
): { status: 'success' | 'warning' | 'error'; message: string } {
|
||||
const draft = this.draft();
|
||||
|
||||
switch (check.id) {
|
||||
case 'auth':
|
||||
if (this.isAuthValid()) {
|
||||
return { status: 'success', message: 'Required credentials are present.' };
|
||||
}
|
||||
return { status: 'error', message: 'Missing required authentication values.' };
|
||||
case 'connectivity':
|
||||
if (draft.provider) {
|
||||
return { status: 'success', message: 'Provider endpoint is reachable.' };
|
||||
}
|
||||
return { status: 'error', message: 'Select a provider before testing connectivity.' };
|
||||
case 'list-repos':
|
||||
if ((draft.scope.repositories?.length ?? 0) > 0) {
|
||||
return { status: 'success', message: 'Repository scope is explicitly defined.' };
|
||||
}
|
||||
return { status: 'warning', message: 'No repository filter defined; full scope will be used.' };
|
||||
case 'pull-manifest':
|
||||
return draft.provider
|
||||
? { status: 'success', message: 'Manifest pull capability validated.' }
|
||||
: { status: 'error', message: 'Provider is required before manifest validation.' };
|
||||
case 'webhook':
|
||||
return draft.webhookEnabled
|
||||
? { status: 'success', message: 'Webhook trigger is enabled.' }
|
||||
: { status: 'warning', message: 'Webhook disabled; scheduled runs will be used.' };
|
||||
case 'permissions':
|
||||
case 'token-scope':
|
||||
return this.isAuthValid()
|
||||
? { status: 'success', message: 'Token permissions satisfy minimum requirements.' }
|
||||
: { status: 'error', message: 'Token scope cannot be verified until auth is valid.' };
|
||||
case 'workflow-access':
|
||||
if ((draft.scope.repositories?.length ?? 0) > 0 || (draft.scope.organizations?.length ?? 0) > 0) {
|
||||
return { status: 'success', message: 'Pipeline scope is configured.' };
|
||||
}
|
||||
return { status: 'warning', message: 'No pipeline scope configured; defaults will apply.' };
|
||||
case 'kernel':
|
||||
case 'btf':
|
||||
case 'privileges':
|
||||
case 'probe-bundle':
|
||||
return { status: 'success', message: 'Host prerequisites validated from selected installer profile.' };
|
||||
default:
|
||||
return { status: 'success', message: 'Check passed.' };
|
||||
private ensureConfigDefaults(
|
||||
existing: Record<string, string>,
|
||||
definition: IntegrationProviderDefinition,
|
||||
): Record<string, string> {
|
||||
const defaults = { ...existing };
|
||||
for (const field of definition.configFields) {
|
||||
defaults[field.id] = defaults[field.id] ?? '';
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
private isAuthValid(): boolean {
|
||||
const d = this.draft();
|
||||
if (!d.authMethod) return false;
|
||||
private isConnectionValid(): boolean {
|
||||
const definition = this.selectedProvider();
|
||||
const draft = this.draft();
|
||||
if (definition === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const method = this.selectedAuthMethod();
|
||||
if (!method) return false;
|
||||
if (draft.endpoint.trim().length === 0 || draft.authRefUri.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return method.fields
|
||||
.filter(f => f.required)
|
||||
.every(f => d.authValues[f.id]?.trim().length > 0);
|
||||
return definition.configFields
|
||||
.filter((field) => field.required)
|
||||
.every((field) => (draft.extendedConfig[field.id] ?? '').trim().length > 0);
|
||||
}
|
||||
|
||||
private isScopeValid(): boolean {
|
||||
const scope = this.draft().scope;
|
||||
// At least one scope field should be defined
|
||||
return !!(
|
||||
(scope.repositories && scope.repositories.length > 0) ||
|
||||
(scope.organizations && scope.organizations.length > 0) ||
|
||||
(scope.namespaces && scope.namespaces.length > 0) ||
|
||||
(scope.branches && scope.branches.length > 0)
|
||||
const draft = this.draft();
|
||||
return (
|
||||
draft.organizationId.trim().length > 0 ||
|
||||
draft.repositories.length > 0 ||
|
||||
draft.branches.length > 0 ||
|
||||
draft.namespaces.length > 0 ||
|
||||
draft.tagPatterns.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
private isScheduleValid(): boolean {
|
||||
const schedule = this.draft().schedule;
|
||||
switch (schedule.type) {
|
||||
case 'manual': return true;
|
||||
case 'interval': return (schedule.intervalMinutes ?? 0) > 0;
|
||||
case 'cron': return (schedule.cronExpression ?? '').trim().length > 0;
|
||||
default: return false;
|
||||
case 'manual':
|
||||
return true;
|
||||
case 'interval':
|
||||
return (schedule.intervalMinutes ?? 0) > 0;
|
||||
case 'cron':
|
||||
return (schedule.cronExpression ?? '').trim().length > 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private isPreflightPassed(): boolean {
|
||||
const checks = this.preflightChecks();
|
||||
if (checks.length === 0) return false;
|
||||
return checks.every(c => c.status === 'success' || c.status === 'warning');
|
||||
return checks.length > 0 && checks.every((check) => check.status === 'success' || check.status === 'warning');
|
||||
}
|
||||
|
||||
private getPreflightChecks(): PreflightCheck[] {
|
||||
const type = this.integrationType();
|
||||
const common: PreflightCheck[] = [
|
||||
{ id: 'auth', name: 'Authentication', description: 'Verify credentials', status: 'pending' },
|
||||
{ id: 'connectivity', name: 'Connectivity', description: 'Test network connection', status: 'pending' },
|
||||
const provider = this.selectedProvider();
|
||||
const checks: PreflightCheck[] = [
|
||||
{ id: 'authref', name: 'Credential indirection', description: 'Verify the connector uses an AuthRef URI instead of raw secrets.', status: 'pending' },
|
||||
{ id: 'endpoint', name: 'Endpoint contract', description: 'Confirm the endpoint matches the provider health/test contract.', status: 'pending' },
|
||||
{ id: 'scope', name: 'Discovery scope', description: 'Ensure at least one repository, namespace, branch, or owner scope is set.', status: 'pending' },
|
||||
{ id: 'schedule', name: 'Probe schedule', description: 'Validate the deterministic check cadence for this connector.', status: 'pending' },
|
||||
];
|
||||
|
||||
switch (type) {
|
||||
case 'registry':
|
||||
return [
|
||||
...common,
|
||||
{ id: 'list-repos', name: 'List Repositories', description: 'Enumerate accessible repos', status: 'pending' },
|
||||
{ id: 'pull-manifest', name: 'Pull Manifest', description: 'Test manifest access', status: 'pending' },
|
||||
];
|
||||
case 'scm':
|
||||
return [
|
||||
...common,
|
||||
{ id: 'list-repos', name: 'List Repositories', description: 'Enumerate accessible repos', status: 'pending' },
|
||||
{ id: 'webhook', name: 'Webhook Setup', description: 'Verify webhook configuration', status: 'pending' },
|
||||
{ id: 'permissions', name: 'Permissions', description: 'Check required permissions', status: 'pending' },
|
||||
];
|
||||
case 'ci':
|
||||
return [
|
||||
...common,
|
||||
{ id: 'token-scope', name: 'Token Scope', description: 'Verify token permissions', status: 'pending' },
|
||||
{ id: 'workflow-access', name: 'Workflow Access', description: 'Check workflow trigger access', status: 'pending' },
|
||||
];
|
||||
case 'host':
|
||||
return [
|
||||
{ id: 'kernel', name: 'Kernel Version', description: 'Check kernel compatibility', status: 'pending' },
|
||||
{ id: 'btf', name: 'BTF Support', description: 'Verify BTF availability', status: 'pending' },
|
||||
{ id: 'privileges', name: 'Privileges', description: 'Check required privileges', status: 'pending' },
|
||||
{ id: 'probe-bundle', name: 'Probe Bundle', description: 'Verify probe availability', status: 'pending' },
|
||||
];
|
||||
if (provider?.provider === 200) {
|
||||
checks.push({ id: 'github-app', name: 'GitHub App metadata', description: 'App ID and Installation ID are present as non-secret config.', status: 'pending' });
|
||||
}
|
||||
|
||||
if (provider?.provider === 100) {
|
||||
checks.push({ id: 'harbor-route', name: 'Harbor health route', description: 'Harbor endpoints must answer /api/v2.0/health.', status: 'pending' });
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
private evaluatePreflightResult(
|
||||
check: PreflightCheck,
|
||||
): { status: 'success' | 'warning' | 'error'; message: string } {
|
||||
const draft = this.draft();
|
||||
const provider = this.selectedProvider();
|
||||
|
||||
switch (check.id) {
|
||||
case 'authref':
|
||||
return draft.authRefUri.trim().startsWith('authref://')
|
||||
? { status: 'success', message: 'Credential indirection is configured via AuthRef URI.' }
|
||||
: { status: 'error', message: 'Use an authref:// URI instead of embedding secrets in the connector.' };
|
||||
case 'endpoint':
|
||||
return draft.endpoint.trim().length > 0
|
||||
? { status: 'success', message: `Endpoint ${draft.endpoint.trim()} will be used for connector probes.` }
|
||||
: { status: 'error', message: 'A provider endpoint is required.' };
|
||||
case 'scope':
|
||||
return this.isScopeValid()
|
||||
? { status: 'success', message: 'Connector scope is explicitly defined.' }
|
||||
: { status: 'error', message: 'Set an owner, repository, namespace, branch, or tag scope before creating the connector.' };
|
||||
case 'schedule':
|
||||
return this.isScheduleValid()
|
||||
? { status: 'success', message: `Connector checks will run in ${draft.schedule.type} mode.` }
|
||||
: { status: 'error', message: 'The selected schedule is incomplete.' };
|
||||
case 'github-app':
|
||||
return ['appId', 'installationId'].every((field) => (draft.extendedConfig[field] ?? '').trim().length > 0)
|
||||
? { status: 'success', message: 'GitHub App ID and Installation ID are present.' }
|
||||
: { status: 'error', message: 'GitHub App onboarding requires App ID and Installation ID.' };
|
||||
case 'harbor-route':
|
||||
return draft.endpoint.trim().length > 0
|
||||
? { status: 'warning', message: 'Harbor probes expect /api/v2.0/health on the configured endpoint.' }
|
||||
: { status: 'error', message: 'Harbor requires a base endpoint before the health contract can be evaluated.' };
|
||||
default:
|
||||
return common;
|
||||
}
|
||||
}
|
||||
|
||||
private generateWebhookSecret(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
private buildDeploymentTemplate(): string | null {
|
||||
if (this.integrationType() !== 'host') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const method = this.draft().authMethod;
|
||||
if (method === 'helm') {
|
||||
return this.buildHelmTemplate();
|
||||
}
|
||||
if (method === 'systemd') {
|
||||
return this.buildSystemdTemplate();
|
||||
}
|
||||
if (method === 'offline') {
|
||||
return this.buildOfflineBundleInstructions();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildHelmTemplate(): string {
|
||||
const integrationName = this.sanitizeIdentifier(this.draft().name || 'host-integration');
|
||||
const namespace = this.sanitizeIdentifier(this.draft().authValues['namespace'] || 'stellaops');
|
||||
const valuesOverride = (this.draft().authValues['valuesOverride'] || '').trim();
|
||||
|
||||
let template = [
|
||||
'# Deploy Zastava observer with Helm',
|
||||
'helm repo add stellaops https://charts.stellaops.local',
|
||||
'helm repo update',
|
||||
'',
|
||||
`helm upgrade --install ${integrationName} stellaops/zastava-observer \\`,
|
||||
` --namespace ${namespace} --create-namespace \\`,
|
||||
' --set integration.type=host \\',
|
||||
` --set integration.name=${integrationName} \\`,
|
||||
' --set-string stella.apiToken=${STELLA_API_TOKEN}',
|
||||
].join('\n');
|
||||
|
||||
if (valuesOverride.length > 0) {
|
||||
template += `\n# Optional values override\n# ${valuesOverride}`;
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
private buildSystemdTemplate(): string {
|
||||
const integrationName = this.sanitizeIdentifier(this.draft().name || 'host-integration');
|
||||
const installPath = (this.draft().authValues['installPath'] || '/opt/stellaops').trim();
|
||||
const safeInstallPath = installPath.length > 0 ? installPath : '/opt/stellaops';
|
||||
|
||||
return [
|
||||
'# Install Zastava observer with systemd',
|
||||
`sudo mkdir -p ${safeInstallPath}/bin`,
|
||||
`sudo install -m 0755 ./zastava-observer ${safeInstallPath}/bin/zastava-observer`,
|
||||
'',
|
||||
'cat <<\'UNIT\' | sudo tee /etc/systemd/system/zastava-observer.service',
|
||||
'[Unit]',
|
||||
`Description=StellaOps Zastava Observer (${integrationName})`,
|
||||
'After=network-online.target',
|
||||
'',
|
||||
'[Service]',
|
||||
'Type=simple',
|
||||
`WorkingDirectory=${safeInstallPath}`,
|
||||
'Environment=STELLA_API_URL=https://stella.local',
|
||||
'Environment=STELLA_API_TOKEN=${STELLA_API_TOKEN}',
|
||||
`ExecStart=${safeInstallPath}/bin/zastava-observer --integration ${integrationName}`,
|
||||
'Restart=on-failure',
|
||||
'RestartSec=5',
|
||||
'',
|
||||
'[Install]',
|
||||
'WantedBy=multi-user.target',
|
||||
'UNIT',
|
||||
'',
|
||||
'sudo systemctl daemon-reload',
|
||||
'sudo systemctl enable --now zastava-observer',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private buildOfflineBundleInstructions(): string {
|
||||
return [
|
||||
'# Offline bundle deployment',
|
||||
'1. Download the latest signed offline bundle from StellaOps.',
|
||||
'2. Transfer the bundle to the target host through approved media.',
|
||||
'3. Verify signatures with `stella verify-bundle --path ./bundle.tar.zst`.',
|
||||
'4. Run `stella install-offline --bundle ./bundle.tar.zst --token ${STELLA_API_TOKEN}`.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private getCopySafetyGuidance(): string[] {
|
||||
return [
|
||||
'Use placeholder variables in shared docs, never real secrets.',
|
||||
'Store tokens in environment variables or a secret manager.',
|
||||
'Rotate credentials immediately if they are exposed in logs or chat.',
|
||||
];
|
||||
}
|
||||
|
||||
private sanitizeIdentifier(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'stellaops-host';
|
||||
}
|
||||
|
||||
getStepLabel(step: WizardStep): string {
|
||||
switch (step) {
|
||||
case 'provider': return 'Provider';
|
||||
case 'auth': return 'Authentication';
|
||||
case 'scope': return 'Scope';
|
||||
case 'schedule': return 'Schedule';
|
||||
case 'preflight': return 'Preflight';
|
||||
case 'review': return 'Review';
|
||||
default: return step;
|
||||
}
|
||||
}
|
||||
|
||||
getTypeLabel(): string {
|
||||
switch (this.integrationType()) {
|
||||
case 'registry': return 'Registry';
|
||||
case 'scm': return 'SCM';
|
||||
case 'ci': return 'CI/CD';
|
||||
case 'host': return 'Host';
|
||||
default: return 'Integration';
|
||||
return provider
|
||||
? { status: 'success', message: `${provider.name} onboarding draft is internally consistent.` }
|
||||
: { status: 'error', message: 'Select a supported provider first.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
|
||||
import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { IntegrationWizardComponent } from './integration-wizard.component';
|
||||
import { timeout } from 'rxjs';
|
||||
|
||||
import { IntegrationService } from '../integration-hub/integration.service';
|
||||
import { CreateIntegrationRequest, SupportedProviderInfo } from '../integration-hub/integration.models';
|
||||
import { integrationWorkspaceCommands } from '../integration-hub/integration-route-context';
|
||||
import { IntegrationWizardComponent } from './integration-wizard.component';
|
||||
import {
|
||||
IntegrationType,
|
||||
IntegrationDraft,
|
||||
REGISTRY_PROVIDERS,
|
||||
SCM_PROVIDERS,
|
||||
CI_PROVIDERS,
|
||||
HOST_PROVIDERS,
|
||||
IntegrationOnboardingType,
|
||||
resolveSupportedProviders,
|
||||
} from './models/integration.models';
|
||||
|
||||
/**
|
||||
* Integrations Hub Page (Sprint: SPRINT_20251229_014)
|
||||
* Central page for managing all integrations with wizard access.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-integrations-hub',
|
||||
standalone: true,
|
||||
@@ -25,77 +20,97 @@ import {
|
||||
@if (!activeWizard()) {
|
||||
<header class="hub-header">
|
||||
<h1>Integrations</h1>
|
||||
<p>Connect StellaOps to your registries, SCM providers, CI/CD pipelines, and hosts.</p>
|
||||
<p>Connect StellaOps to the providers that are actually installed in this environment.</p>
|
||||
</header>
|
||||
|
||||
<div class="integration-categories">
|
||||
<!-- Registry Integrations -->
|
||||
<section class="category-section">
|
||||
<div class="category-header">
|
||||
<h2>Container Registries</h2>
|
||||
<button class="btn btn-primary" (click)="openWizard('registry')">
|
||||
+ Add Registry
|
||||
</button>
|
||||
</div>
|
||||
<p class="category-desc">Connect container registries for automated image scanning.</p>
|
||||
<div class="provider-pills">
|
||||
@for (p of registryProviders; track p.id) {
|
||||
<span class="provider-pill">{{ p.name }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (loadingCatalog()) {
|
||||
<section class="catalog-state" role="status">
|
||||
<h2>Loading provider catalog</h2>
|
||||
<p>Reading the installed connector plugins from the integrations service.</p>
|
||||
</section>
|
||||
} @else if (loadError()) {
|
||||
<section class="catalog-state error-state" role="status">
|
||||
<h2>Connector catalog unavailable</h2>
|
||||
<p>{{ loadError() }}</p>
|
||||
<button class="btn btn-primary" type="button" (click)="loadProviderCatalog()">Retry</button>
|
||||
</section>
|
||||
} @else {
|
||||
<div class="integration-categories">
|
||||
<section class="category-section">
|
||||
<div class="category-header">
|
||||
<div>
|
||||
<h2>Container Registries</h2>
|
||||
<p class="category-desc">Connect container registries for image discovery, probing, and policy handoff.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" (click)="openWizard('registry')" [disabled]="registryProviders().length === 0">
|
||||
+ Add Registry
|
||||
</button>
|
||||
</div>
|
||||
@if (registryProviders().length > 0) {
|
||||
<div class="provider-pills">
|
||||
@for (provider of registryProviders(); track provider.provider) {
|
||||
<span class="provider-pill">{{ provider.name }}</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="category-empty">No registry connector plugins are installed in this environment.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- SCM Integrations -->
|
||||
<section class="category-section">
|
||||
<div class="category-header">
|
||||
<h2>Source Control</h2>
|
||||
<button class="btn btn-primary" (click)="openWizard('scm')">
|
||||
+ Add SCM
|
||||
</button>
|
||||
</div>
|
||||
<p class="category-desc">Connect SCM providers for repository and webhook integration.</p>
|
||||
<div class="provider-pills">
|
||||
@for (p of scmProviders; track p.id) {
|
||||
<span class="provider-pill">{{ p.name }}</span>
|
||||
<section class="category-section">
|
||||
<div class="category-header">
|
||||
<div>
|
||||
<h2>Source Control</h2>
|
||||
<p class="category-desc">Connect repository hosts using credential indirection and deterministic discovery scope.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" (click)="openWizard('scm')" [disabled]="scmProviders().length === 0">
|
||||
+ Add SCM
|
||||
</button>
|
||||
</div>
|
||||
@if (scmProviders().length > 0) {
|
||||
<div class="provider-pills">
|
||||
@for (provider of scmProviders(); track provider.provider) {
|
||||
<span class="provider-pill">{{ provider.name }}</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="category-empty">No SCM connector plugins are installed in this environment.</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- CI/CD Integrations -->
|
||||
<section class="category-section">
|
||||
<div class="category-header">
|
||||
<h2>CI/CD Pipelines</h2>
|
||||
<button class="btn btn-primary" (click)="openWizard('ci')">
|
||||
+ Add CI/CD
|
||||
</button>
|
||||
</div>
|
||||
<p class="category-desc">Integrate with CI/CD platforms for pipeline-triggered scans.</p>
|
||||
<div class="provider-pills">
|
||||
@for (p of ciProviders; track p.id) {
|
||||
<span class="provider-pill">{{ p.name }}</span>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<section class="category-section">
|
||||
<div class="category-header">
|
||||
<div>
|
||||
<h2>CI/CD Pipelines</h2>
|
||||
<p class="category-desc">CI onboarding stays unavailable until a real pipeline connector plugin is installed.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" (click)="openWizard('ci')" [disabled]="ciProviders().length === 0">
|
||||
+ Add CI/CD
|
||||
</button>
|
||||
</div>
|
||||
<p class="category-empty">No CI/CD connector plugins are currently available.</p>
|
||||
</section>
|
||||
|
||||
<!-- Host Integrations -->
|
||||
<section class="category-section">
|
||||
<div class="category-header">
|
||||
<h2>Hosts & Observers</h2>
|
||||
<button class="btn btn-primary" (click)="openWizard('host')">
|
||||
+ Add Host
|
||||
</button>
|
||||
</div>
|
||||
<p class="category-desc">Deploy Zastava observers for runtime signal collection.</p>
|
||||
<div class="provider-pills">
|
||||
@for (p of hostProviders; track p.id) {
|
||||
<span class="provider-pill">{{ p.name }}</span>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section class="category-section">
|
||||
<div class="category-header">
|
||||
<div>
|
||||
<h2>Hosts & Observers</h2>
|
||||
<p class="category-desc">Runtime-host onboarding stays unavailable until a real host connector plugin is installed.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" (click)="openWizard('host')" [disabled]="hostProviders().length === 0">
|
||||
+ Add Host
|
||||
</button>
|
||||
</div>
|
||||
<p class="category-empty">No runtime-host connector plugins are currently available.</p>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<app-integration-wizard
|
||||
[integrationType]="activeWizard()!"
|
||||
[supportedProviders]="providersForType(activeWizard()!)"
|
||||
[creating]="saving()"
|
||||
[errorMessage]="saveError()"
|
||||
(cancel)="closeWizard()"
|
||||
(create)="onIntegrationCreated($event)"
|
||||
/>
|
||||
@@ -111,17 +126,36 @@ import {
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
.hub-header h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.hub-header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 56rem;
|
||||
}
|
||||
|
||||
.catalog-state {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.catalog-state h2,
|
||||
.catalog-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
border-color: var(--color-status-error-bg);
|
||||
}
|
||||
|
||||
.integration-categories {
|
||||
@@ -134,39 +168,42 @@ import {
|
||||
padding: 1.5rem;
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
.category-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
}
|
||||
.category-header h2 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.category-desc {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.category-desc,
|
||||
.category-empty {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.provider-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.provider-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.provider-pill {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: var(--color-surface-tertiary);
|
||||
border-radius: var(--radius-2xl);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.provider-pill {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: var(--color-surface-tertiary);
|
||||
border-radius: var(--radius-2xl);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -175,14 +212,21 @@ import {
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
.btn.btn-primary {
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--color-brand-primary-hover);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.category-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
`],
|
||||
@@ -191,62 +235,122 @@ import {
|
||||
export class IntegrationsHubComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly integrationService = inject(IntegrationService);
|
||||
private readonly requestTimeoutMs = 12_000;
|
||||
|
||||
readonly activeWizard = signal<IntegrationType | null>(null);
|
||||
readonly activeWizard = signal<IntegrationOnboardingType | null>(null);
|
||||
readonly supportedCatalog = signal<readonly SupportedProviderInfo[]>([]);
|
||||
readonly loadingCatalog = signal(true);
|
||||
readonly loadError = signal<string | null>(null);
|
||||
readonly saving = signal(false);
|
||||
readonly saveError = signal<string | null>(null);
|
||||
|
||||
readonly registryProviders = REGISTRY_PROVIDERS;
|
||||
readonly scmProviders = SCM_PROVIDERS;
|
||||
readonly ciProviders = CI_PROVIDERS;
|
||||
readonly hostProviders = HOST_PROVIDERS;
|
||||
readonly registryProviders = computed(() => resolveSupportedProviders('registry', this.supportedCatalog()));
|
||||
readonly scmProviders = computed(() => resolveSupportedProviders('scm', this.supportedCatalog()));
|
||||
readonly ciProviders = computed(() => resolveSupportedProviders('ci', this.supportedCatalog()));
|
||||
readonly hostProviders = computed(() => resolveSupportedProviders('host', this.supportedCatalog()));
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.paramMap.subscribe((params) => {
|
||||
const type = params.get('type');
|
||||
this.activeWizard.set(this.parseWizardType(type));
|
||||
});
|
||||
|
||||
this.loadProviderCatalog();
|
||||
}
|
||||
|
||||
openWizard(type: IntegrationType): void {
|
||||
loadProviderCatalog(): void {
|
||||
this.loadingCatalog.set(true);
|
||||
this.loadError.set(null);
|
||||
|
||||
this.integrationService.getSupportedProviders().pipe(
|
||||
timeout({ first: this.requestTimeoutMs }),
|
||||
).subscribe({
|
||||
next: (catalog) => {
|
||||
this.supportedCatalog.set(catalog);
|
||||
this.loadingCatalog.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.supportedCatalog.set([]);
|
||||
this.loadingCatalog.set(false);
|
||||
this.loadError.set(
|
||||
err?.name === 'TimeoutError'
|
||||
? 'The integrations service did not return its provider catalog in time. Retry or verify the frontdoor path.'
|
||||
: 'The integrations service provider catalog could not be loaded.',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openWizard(type: IntegrationOnboardingType): void {
|
||||
if (this.providersForType(type).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeWizard.set(type);
|
||||
void this.router.navigate(this.integrationCommands('onboarding', type));
|
||||
this.saveError.set(null);
|
||||
void this.router.navigate(this.integrationCommands('onboarding', type), {
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
}
|
||||
|
||||
closeWizard(): void {
|
||||
this.activeWizard.set(null);
|
||||
void this.router.navigate(this.integrationCommands('onboarding'));
|
||||
this.saveError.set(null);
|
||||
this.saving.set(false);
|
||||
void this.router.navigate(this.integrationCommands('onboarding'), {
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
}
|
||||
|
||||
onIntegrationCreated(draft: IntegrationDraft): void {
|
||||
this.closeWizard();
|
||||
void this.router.navigate(this.integrationCommands(this.getIntegrationListPath(draft.type)));
|
||||
onIntegrationCreated(request: CreateIntegrationRequest): void {
|
||||
this.saving.set(true);
|
||||
this.saveError.set(null);
|
||||
|
||||
this.integrationService.create(request).pipe(
|
||||
timeout({ first: this.requestTimeoutMs }),
|
||||
).subscribe({
|
||||
next: (created) => {
|
||||
this.saving.set(false);
|
||||
void this.router.navigate(this.integrationCommands(created.id), {
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving.set(false);
|
||||
this.saveError.set(
|
||||
err?.name === 'TimeoutError'
|
||||
? 'Creating the integration timed out before the service responded.'
|
||||
: 'The integration could not be created. Verify the provider fields and try again.',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private parseWizardType(type: string | null): IntegrationType | null {
|
||||
providersForType(type: IntegrationOnboardingType) {
|
||||
switch (type) {
|
||||
case 'registry':
|
||||
return 'registry';
|
||||
return this.registryProviders();
|
||||
case 'scm':
|
||||
return 'scm';
|
||||
return this.scmProviders();
|
||||
case 'ci':
|
||||
return 'ci';
|
||||
return this.ciProviders();
|
||||
case 'host':
|
||||
return 'host';
|
||||
return this.hostProviders();
|
||||
default:
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private getIntegrationListPath(type: IntegrationType | null): string {
|
||||
private parseWizardType(type: string | null): IntegrationOnboardingType | null {
|
||||
switch (type) {
|
||||
case 'scm':
|
||||
return 'scm';
|
||||
case 'ci':
|
||||
return 'ci';
|
||||
case 'host':
|
||||
return 'runtime-hosts';
|
||||
case 'registry':
|
||||
case 'scm':
|
||||
case 'ci':
|
||||
case 'host':
|
||||
return type;
|
||||
default:
|
||||
return 'registries';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +1,34 @@
|
||||
/**
|
||||
* Integration types and wizard models (Sprint: SPRINT_20251229_014)
|
||||
*/
|
||||
|
||||
export type IntegrationProvider =
|
||||
| 'docker-hub'
|
||||
| 'harbor'
|
||||
| 'ecr'
|
||||
| 'acr'
|
||||
| 'gcr'
|
||||
| 'ghcr'
|
||||
| 'github'
|
||||
| 'gitlab'
|
||||
| 'gitea'
|
||||
| 'github-actions'
|
||||
| 'gitlab-ci'
|
||||
| 'gitea-actions'
|
||||
| 'kubernetes'
|
||||
| 'vm'
|
||||
| 'baremetal';
|
||||
|
||||
export type IntegrationType = 'registry' | 'scm' | 'ci' | 'host';
|
||||
import {
|
||||
CreateIntegrationRequest,
|
||||
IntegrationProvider,
|
||||
IntegrationType,
|
||||
SupportedProviderInfo,
|
||||
} from '../../integration-hub/integration.models';
|
||||
|
||||
export type IntegrationOnboardingType = 'registry' | 'scm' | 'ci' | 'host';
|
||||
export type WizardStep = 'provider' | 'auth' | 'scope' | 'schedule' | 'preflight' | 'review';
|
||||
|
||||
export interface IntegrationProviderInfo {
|
||||
id: IntegrationProvider;
|
||||
name: string;
|
||||
type: IntegrationType;
|
||||
icon: string;
|
||||
description: string;
|
||||
docsUrl?: string;
|
||||
}
|
||||
|
||||
export interface AuthMethod {
|
||||
export interface ProviderField {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
fields: AuthField[];
|
||||
}
|
||||
|
||||
export interface AuthField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'text' | 'password' | 'select' | 'checkbox';
|
||||
label: string;
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
options?: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export interface IntegrationProviderDefinition {
|
||||
provider: IntegrationProvider;
|
||||
type: IntegrationOnboardingType;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
defaultEndpoint: string;
|
||||
endpointHint: string;
|
||||
authRefHint: string;
|
||||
organizationLabel?: string;
|
||||
organizationHint?: string;
|
||||
exposeInUi: boolean;
|
||||
configFields: ProviderField[];
|
||||
}
|
||||
|
||||
export interface PreflightCheck {
|
||||
@@ -60,149 +42,183 @@ export interface PreflightCheck {
|
||||
export interface IntegrationDraft {
|
||||
name: string;
|
||||
provider: IntegrationProvider | null;
|
||||
type: IntegrationType | null;
|
||||
authMethod: string | null;
|
||||
authValues: Record<string, string>;
|
||||
scope: IntegrationScope;
|
||||
type: IntegrationOnboardingType | null;
|
||||
endpoint: string;
|
||||
authRefUri: string;
|
||||
organizationId: string;
|
||||
repositories: string[];
|
||||
branches: string[];
|
||||
namespaces: string[];
|
||||
tagPatterns: string[];
|
||||
schedule: IntegrationSchedule;
|
||||
webhookEnabled: boolean;
|
||||
webhookSecret?: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface IntegrationScope {
|
||||
repositories?: string[];
|
||||
branches?: string[];
|
||||
organizations?: string[];
|
||||
namespaces?: string[];
|
||||
tagPatterns?: string[];
|
||||
environments?: string[];
|
||||
extendedConfig: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IntegrationSchedule {
|
||||
type: 'manual' | 'interval' | 'cron';
|
||||
intervalMinutes?: number;
|
||||
cronExpression?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export const REGISTRY_PROVIDERS: IntegrationProviderInfo[] = [
|
||||
{ id: 'docker-hub', name: 'Docker Hub', type: 'registry', icon: 'D', description: 'Public and private Docker registries' },
|
||||
{ id: 'harbor', name: 'Harbor', type: 'registry', icon: 'H', description: 'Self-hosted Harbor registry' },
|
||||
{ id: 'ecr', name: 'Amazon ECR', type: 'registry', icon: 'A', description: 'AWS Elastic Container Registry' },
|
||||
{ id: 'acr', name: 'Azure ACR', type: 'registry', icon: 'Z', description: 'Azure Container Registry' },
|
||||
{ id: 'gcr', name: 'Google GCR', type: 'registry', icon: 'G', description: 'Google Container Registry / Artifact Registry' },
|
||||
{ id: 'ghcr', name: 'GitHub GHCR', type: 'registry', icon: 'GH', description: 'GitHub Container Registry' },
|
||||
];
|
||||
const ALL_PROVIDER_DEFINITIONS: readonly IntegrationProviderDefinition[] = [
|
||||
{
|
||||
provider: IntegrationProvider.Harbor,
|
||||
type: 'registry',
|
||||
name: 'Harbor',
|
||||
icon: 'H',
|
||||
description: 'Self-hosted Harbor registry with robot-account or basic-auth health probes.',
|
||||
defaultEndpoint: 'https://harbor.local',
|
||||
endpointHint: 'Use the Harbor base URL; StellaOps probes /api/v2.0/health.',
|
||||
authRefHint: 'Reference a robot-account or username:password secret, for example authref://vault/harbor#robot-account.',
|
||||
organizationLabel: 'Project / Namespace',
|
||||
organizationHint: 'Optional Harbor project scope used for list and policy views.',
|
||||
exposeInUi: true,
|
||||
configFields: [],
|
||||
},
|
||||
{
|
||||
provider: IntegrationProvider.GitHubApp,
|
||||
type: 'scm',
|
||||
name: 'GitHub App',
|
||||
icon: 'GH',
|
||||
description: 'GitHub App installation with App ID and Installation ID stored as non-secret config.',
|
||||
defaultEndpoint: 'https://github.com',
|
||||
endpointHint: 'Use https://github.com for GitHub Cloud or your GitHub Enterprise Server base URL.',
|
||||
authRefHint: 'Reference a vault secret that resolves to the app JWT or installation token.',
|
||||
organizationLabel: 'Owner / Organization',
|
||||
organizationHint: 'Optional owner used to scope repository discovery and policy views.',
|
||||
exposeInUi: true,
|
||||
configFields: [
|
||||
{
|
||||
id: 'appId',
|
||||
label: 'GitHub App ID',
|
||||
required: true,
|
||||
placeholder: '123456',
|
||||
},
|
||||
{
|
||||
id: 'installationId',
|
||||
label: 'Installation ID',
|
||||
required: true,
|
||||
placeholder: '7890123',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: IntegrationProvider.InMemory,
|
||||
type: 'registry',
|
||||
name: 'In-Memory',
|
||||
icon: 'IM',
|
||||
description: 'Deterministic connector used for internal testing only.',
|
||||
defaultEndpoint: 'http://inmemory.local',
|
||||
endpointHint: 'Internal-only testing connector.',
|
||||
authRefHint: 'Internal-only testing connector.',
|
||||
exposeInUi: false,
|
||||
configFields: [],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const SCM_PROVIDERS: IntegrationProviderInfo[] = [
|
||||
{ id: 'github', name: 'GitHub', type: 'scm', icon: 'GH', description: 'GitHub repositories and organizations' },
|
||||
{ id: 'gitlab', name: 'GitLab', type: 'scm', icon: 'GL', description: 'GitLab projects and groups' },
|
||||
{ id: 'gitea', name: 'Gitea', type: 'scm', icon: 'GT', description: 'Self-hosted Gitea repositories' },
|
||||
];
|
||||
export const REGISTRY_PROVIDERS = ALL_PROVIDER_DEFINITIONS.filter((provider) => provider.type === 'registry');
|
||||
export const SCM_PROVIDERS = ALL_PROVIDER_DEFINITIONS.filter((provider) => provider.type === 'scm');
|
||||
export const CI_PROVIDERS = ALL_PROVIDER_DEFINITIONS.filter((provider) => provider.type === 'ci');
|
||||
export const HOST_PROVIDERS = ALL_PROVIDER_DEFINITIONS.filter((provider) => provider.type === 'host');
|
||||
|
||||
export const CI_PROVIDERS: IntegrationProviderInfo[] = [
|
||||
{ id: 'github-actions', name: 'GitHub Actions', type: 'ci', icon: 'GH', description: 'GitHub Actions workflows' },
|
||||
{ id: 'gitlab-ci', name: 'GitLab CI', type: 'ci', icon: 'GL', description: 'GitLab CI/CD pipelines' },
|
||||
{ id: 'gitea-actions', name: 'Gitea Actions', type: 'ci', icon: 'GT', description: 'Gitea Actions workflows' },
|
||||
];
|
||||
export const scheduleOptions = [
|
||||
{ value: 'manual', label: 'Manual', description: 'Create the connector now and trigger checks on demand.' },
|
||||
{ value: 'interval', label: 'Interval', description: 'Run connector checks at a fixed interval.' },
|
||||
{ value: 'cron', label: 'Cron', description: 'Use a cron expression when the platform should probe or sync.' },
|
||||
] as const;
|
||||
|
||||
export const HOST_PROVIDERS: IntegrationProviderInfo[] = [
|
||||
{ id: 'kubernetes', name: 'Kubernetes', type: 'host', icon: 'K8', description: 'Kubernetes cluster with Helm/DaemonSet' },
|
||||
{ id: 'vm', name: 'Virtual Machine', type: 'host', icon: 'VM', description: 'VM with systemd service' },
|
||||
{ id: 'baremetal', name: 'Bare Metal', type: 'host', icon: 'BM', description: 'Bare metal server with agent' },
|
||||
];
|
||||
export const intervalOptions = [
|
||||
{ value: 15, label: '15 minutes' },
|
||||
{ value: 30, label: '30 minutes' },
|
||||
{ value: 60, label: '1 hour' },
|
||||
{ value: 360, label: '6 hours' },
|
||||
{ value: 720, label: '12 hours' },
|
||||
{ value: 1440, label: '24 hours' },
|
||||
] as const;
|
||||
|
||||
export const AUTH_METHODS: Record<IntegrationType, AuthMethod[]> = {
|
||||
registry: [
|
||||
{
|
||||
id: 'basic',
|
||||
name: 'Username & Password',
|
||||
description: 'Basic authentication with registry credentials',
|
||||
fields: [
|
||||
{ id: 'username', name: 'Username', type: 'text', required: true, placeholder: 'Registry username' },
|
||||
{ id: 'password', name: 'Password', type: 'password', required: true, placeholder: 'Registry password or token' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'token',
|
||||
name: 'Access Token',
|
||||
description: 'Token-based authentication',
|
||||
fields: [
|
||||
{ id: 'token', name: 'Access Token', type: 'password', required: true, placeholder: 'Registry access token' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'aws-iam',
|
||||
name: 'AWS IAM Role',
|
||||
description: 'Authenticate using AWS IAM role',
|
||||
fields: [
|
||||
{ id: 'region', name: 'AWS Region', type: 'text', required: true, placeholder: 'us-east-1' },
|
||||
{ id: 'roleArn', name: 'Role ARN', type: 'text', required: false, placeholder: 'arn:aws:iam::...:role/...' },
|
||||
],
|
||||
},
|
||||
],
|
||||
scm: [
|
||||
{
|
||||
id: 'github-app',
|
||||
name: 'GitHub App',
|
||||
description: 'Recommended: Install a GitHub App for fine-grained permissions',
|
||||
fields: [
|
||||
{ id: 'appId', name: 'App ID', type: 'text', required: true, placeholder: 'GitHub App ID' },
|
||||
{ id: 'installationId', name: 'Installation ID', type: 'text', required: true, placeholder: 'Installation ID' },
|
||||
{ id: 'privateKey', name: 'Private Key', type: 'password', required: true, hint: 'PEM-encoded private key' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pat',
|
||||
name: 'Personal Access Token',
|
||||
description: 'Use a personal access token with repo scope',
|
||||
fields: [
|
||||
{ id: 'token', name: 'Access Token', type: 'password', required: true, placeholder: 'ghp_..., glpat-..., or Gitea token' },
|
||||
],
|
||||
},
|
||||
],
|
||||
ci: [
|
||||
{
|
||||
id: 'oidc',
|
||||
name: 'OIDC Token Exchange',
|
||||
description: 'Recommended: Use OIDC for keyless authentication',
|
||||
fields: [
|
||||
{ id: 'audience', name: 'Audience', type: 'text', required: true, placeholder: 'stellaops' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'token',
|
||||
name: 'Service Token',
|
||||
description: 'Use a service account token',
|
||||
fields: [
|
||||
{ id: 'token', name: 'Service Token', type: 'password', required: true, placeholder: 'Service account token' },
|
||||
],
|
||||
},
|
||||
],
|
||||
host: [
|
||||
{
|
||||
id: 'helm',
|
||||
name: 'Helm Chart',
|
||||
description: 'Deploy agent using Helm chart',
|
||||
fields: [
|
||||
{ id: 'namespace', name: 'Namespace', type: 'text', required: true, placeholder: 'stellaops' },
|
||||
{ id: 'valuesOverride', name: 'Values Override', type: 'text', required: false, hint: 'YAML values to override' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'systemd',
|
||||
name: 'Systemd Service',
|
||||
description: 'Install agent as systemd service',
|
||||
fields: [
|
||||
{ id: 'installPath', name: 'Install Path', type: 'text', required: true, placeholder: '/opt/stellaops' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'offline',
|
||||
name: 'Offline Bundle',
|
||||
description: 'Download offline bundle for air-gapped deployment',
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
export function resolveSupportedProviders(
|
||||
type: IntegrationOnboardingType,
|
||||
catalog: readonly SupportedProviderInfo[],
|
||||
): IntegrationProviderDefinition[] {
|
||||
const supportedProviderIds = new Set(catalog.map((item) => item.provider));
|
||||
|
||||
return ALL_PROVIDER_DEFINITIONS
|
||||
.filter((provider) => provider.type === type && provider.exposeInUi && supportedProviderIds.has(provider.provider));
|
||||
}
|
||||
|
||||
export function resolveProviderDefinition(provider: IntegrationProvider | null): IntegrationProviderDefinition | null {
|
||||
if (provider === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ALL_PROVIDER_DEFINITIONS.find((item) => item.provider === provider && item.exposeInUi) ?? null;
|
||||
}
|
||||
|
||||
export function toBackendIntegrationType(type: IntegrationOnboardingType | null): IntegrationType | null {
|
||||
switch (type) {
|
||||
case 'registry':
|
||||
return IntegrationType.Registry;
|
||||
case 'scm':
|
||||
return IntegrationType.Scm;
|
||||
case 'ci':
|
||||
return IntegrationType.CiCd;
|
||||
case 'host':
|
||||
return IntegrationType.RuntimeHost;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCreateIntegrationRequest(draft: IntegrationDraft): CreateIntegrationRequest | null {
|
||||
const backendType = toBackendIntegrationType(draft.type);
|
||||
if (backendType === null || draft.provider === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extendedConfig: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(draft.extendedConfig)) {
|
||||
const trimmedValue = value.trim();
|
||||
if (trimmedValue.length > 0) {
|
||||
extendedConfig[key] = trimmedValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.repositories.length > 0) {
|
||||
extendedConfig['repositories'] = draft.repositories;
|
||||
}
|
||||
if (draft.branches.length > 0) {
|
||||
extendedConfig['branches'] = draft.branches;
|
||||
}
|
||||
if (draft.namespaces.length > 0) {
|
||||
extendedConfig['namespaces'] = draft.namespaces;
|
||||
}
|
||||
if (draft.tagPatterns.length > 0) {
|
||||
extendedConfig['tagPatterns'] = draft.tagPatterns;
|
||||
}
|
||||
|
||||
extendedConfig['scheduleType'] = draft.schedule.type;
|
||||
if (draft.schedule.type === 'interval' && draft.schedule.intervalMinutes) {
|
||||
extendedConfig['intervalMinutes'] = draft.schedule.intervalMinutes;
|
||||
}
|
||||
if (draft.schedule.type === 'cron' && draft.schedule.cronExpression) {
|
||||
extendedConfig['cronExpression'] = draft.schedule.cronExpression;
|
||||
}
|
||||
if (draft.webhookEnabled) {
|
||||
extendedConfig['webhookEnabled'] = true;
|
||||
}
|
||||
|
||||
return {
|
||||
name: draft.name.trim(),
|
||||
description: null,
|
||||
type: backendType,
|
||||
provider: draft.provider,
|
||||
endpoint: draft.endpoint.trim(),
|
||||
authRefUri: draft.authRefUri.trim() || null,
|
||||
organizationId: draft.organizationId.trim() || null,
|
||||
extendedConfig: Object.keys(extendedConfig).length > 0 ? extendedConfig : null,
|
||||
tags: draft.tags.length > 0 ? draft.tags : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import 'zone.js';
|
||||
import 'zone.js/testing';
|
||||
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
import { ɵresolveComponentResources as resolveComponentResources } from '@angular/core';
|
||||
import {
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting,
|
||||
} from '@angular/platform-browser-dynamic/testing';
|
||||
|
||||
/**
|
||||
* Jasmine-to-Vitest compatibility shim.
|
||||
*
|
||||
@@ -8,7 +20,82 @@
|
||||
*
|
||||
* This file bridges those APIs so existing specs work without mass refactoring.
|
||||
*/
|
||||
import { vi, type Mock } from 'vitest';
|
||||
import { beforeEach, vi, type Mock } from 'vitest';
|
||||
|
||||
const angularTestEnvironmentKey = '__stellaAngularTestEnvironmentInitialized__';
|
||||
const angularTestResourceRoot = join(process.cwd(), 'src');
|
||||
const angularTestResourcePathCache = new Map<string, string>();
|
||||
const angularTestResourceContentCache = new Map<string, string>();
|
||||
|
||||
async function findAngularTestResourcePath(
|
||||
searchRoot: string,
|
||||
targetFileName: string,
|
||||
): Promise<string | null> {
|
||||
const entries = await readdir(searchRoot, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const candidate = join(searchRoot, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const nestedMatch = await findAngularTestResourcePath(candidate, targetFileName);
|
||||
if (nestedMatch !== null) {
|
||||
return nestedMatch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name === targetFileName) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveAngularTestResource(url: string): Promise<string> {
|
||||
const normalizedUrl = url.replace(/\\/g, '/');
|
||||
const cachedContent = angularTestResourceContentCache.get(normalizedUrl);
|
||||
if (cachedContent !== undefined) {
|
||||
return cachedContent;
|
||||
}
|
||||
|
||||
const targetFileName = basename(normalizedUrl);
|
||||
let resourcePath = angularTestResourcePathCache.get(targetFileName) ?? null;
|
||||
if (resourcePath === null) {
|
||||
resourcePath = await findAngularTestResourcePath(angularTestResourceRoot, targetFileName);
|
||||
if (resourcePath === null) {
|
||||
throw new Error(`Unable to resolve Angular test resource: ${url}`);
|
||||
}
|
||||
|
||||
angularTestResourcePathCache.set(targetFileName, resourcePath);
|
||||
}
|
||||
|
||||
const content = await readFile(resourcePath, 'utf8');
|
||||
angularTestResourceContentCache.set(normalizedUrl, content);
|
||||
return content;
|
||||
}
|
||||
|
||||
if (!(globalThis as Record<string, unknown>)[angularTestEnvironmentKey]) {
|
||||
try {
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting(),
|
||||
{
|
||||
teardown: { destroyAfterEach: true },
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (!message.includes('Cannot set base providers because it has already been called')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
(globalThis as Record<string, unknown>)[angularTestEnvironmentKey] = true;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await resolveComponentResources(resolveAngularTestResource);
|
||||
getTestBed().resetTestingModule();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// jasmine.createSpy → vi.fn
|
||||
|
||||
@@ -1,127 +1,125 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { IntegrationsHubComponent } from '../../app/features/integrations/integrations-hub.component';
|
||||
import { IntegrationType } from '../../app/features/integrations/models/integration.models';
|
||||
import { IntegrationWizardComponent } from '../../app/features/integrations/integration-wizard.component';
|
||||
import { IntegrationService } from '../../app/features/integration-hub/integration.service';
|
||||
import {
|
||||
CreateIntegrationRequest,
|
||||
IntegrationProvider,
|
||||
IntegrationType,
|
||||
SupportedProviderInfo,
|
||||
} from '../../app/features/integration-hub/integration.models';
|
||||
|
||||
describe('Integration Onboarding Wizard (integration_hub)', () => {
|
||||
describe('IntegrationsHubComponent route wiring', () => {
|
||||
let fixture: ComponentFixture<IntegrationsHubComponent>;
|
||||
let component: IntegrationsHubComponent;
|
||||
let router: Router;
|
||||
@Component({
|
||||
selector: 'app-integration-wizard',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class IntegrationWizardStubComponent {
|
||||
readonly integrationType = input.required<'registry' | 'scm' | 'ci' | 'host'>();
|
||||
readonly supportedProviders = input<readonly SupportedProviderInfo[]>([]);
|
||||
readonly creating = input(false);
|
||||
readonly errorMessage = input<string | null>(null);
|
||||
readonly cancel = output<void>();
|
||||
readonly create = output<CreateIntegrationRequest>();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IntegrationsHubComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(convertToParamMap({ type: 'registry' })),
|
||||
},
|
||||
describe('IntegrationsHubComponent onboarding flow', () => {
|
||||
let fixture: ComponentFixture<IntegrationsHubComponent>;
|
||||
let component: IntegrationsHubComponent;
|
||||
let router: Router;
|
||||
let integrationService: jasmine.SpyObj<IntegrationService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
integrationService = jasmine.createSpyObj<IntegrationService>('IntegrationService', ['getSupportedProviders', 'create']);
|
||||
integrationService.getSupportedProviders.and.returnValue(of([
|
||||
{ name: 'harbor', type: IntegrationType.Registry, provider: IntegrationProvider.Harbor },
|
||||
{ name: 'github-app', type: IntegrationType.Scm, provider: IntegrationProvider.GitHubApp },
|
||||
]));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IntegrationsHubComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: IntegrationService, useValue: integrationService },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(convertToParamMap({ type: null })),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(IntegrationsHubComponent, {
|
||||
remove: { imports: [IntegrationWizardComponent] },
|
||||
add: { imports: [IntegrationWizardStubComponent] },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
router = TestBed.inject(Router);
|
||||
fixture = TestBed.createComponent(IntegrationsHubComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('activates wizard from onboarding route param', () => {
|
||||
expect(component.activeWizard()).toBe('registry');
|
||||
});
|
||||
|
||||
it('navigates to typed onboarding route when opening wizard', () => {
|
||||
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
|
||||
component.openWizard('host');
|
||||
|
||||
expect(component.activeWizard()).toBe('host');
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/integrations/onboarding', 'host']);
|
||||
});
|
||||
|
||||
it('returns to onboarding root when wizard closes', () => {
|
||||
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
|
||||
component.closeWizard();
|
||||
|
||||
expect(component.activeWizard()).toBeNull();
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/integrations/onboarding']);
|
||||
});
|
||||
router = TestBed.inject(Router);
|
||||
fixture = TestBed.createComponent(IntegrationsHubComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('IntegrationWizardComponent deterministic behavior', () => {
|
||||
let fixture: ComponentFixture<IntegrationWizardComponent>;
|
||||
let component: IntegrationWizardComponent;
|
||||
it('shows only providers that are backed by installed plugins', () => {
|
||||
expect(component.registryProviders().map((provider) => provider.name)).toEqual(['Harbor']);
|
||||
expect(component.scmProviders().map((provider) => provider.name)).toEqual(['GitHub App']);
|
||||
expect(component.ciProviders()).toEqual([]);
|
||||
expect(component.hostProviders()).toEqual([]);
|
||||
});
|
||||
|
||||
async function createWizard(type: IntegrationType): Promise<void> {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IntegrationWizardComponent],
|
||||
}).compileComponents();
|
||||
it('navigates to typed onboarding only when the category is supported', async () => {
|
||||
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
|
||||
fixture = TestBed.createComponent(IntegrationWizardComponent);
|
||||
fixture.componentRef.setInput('integrationType', type);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
}
|
||||
component.openWizard('registry');
|
||||
component.openWizard('ci');
|
||||
|
||||
it('produces deterministic preflight check results across reruns', async () => {
|
||||
await createWizard('registry');
|
||||
component.selectProvider('docker-hub');
|
||||
component.selectAuthMethod('token');
|
||||
component.updateAuthValue('token', 'qa-token');
|
||||
component.parseScopeInput('repositories', 'team/api');
|
||||
expect(navigateSpy).toHaveBeenCalledOnceWith(['/ops/integrations', 'onboarding', 'registry'], {
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
expect(component.activeWizard()).toBe('registry');
|
||||
});
|
||||
|
||||
await component.runPreflightChecks();
|
||||
const firstRun = component.preflightChecks().map((check) => ({
|
||||
id: check.id,
|
||||
status: check.status,
|
||||
message: check.message,
|
||||
}));
|
||||
it('creates the integration and routes to the created detail page', () => {
|
||||
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||
integrationService.create.and.returnValue(of({
|
||||
id: 'int-42',
|
||||
name: 'QA Harbor',
|
||||
description: null,
|
||||
type: IntegrationType.Registry,
|
||||
provider: IntegrationProvider.Harbor,
|
||||
status: 0,
|
||||
endpoint: 'https://harbor.example.com',
|
||||
hasAuth: true,
|
||||
organizationId: 'platform',
|
||||
lastHealthStatus: 0,
|
||||
lastHealthCheckAt: null,
|
||||
createdAt: '2026-03-14T10:00:00Z',
|
||||
updatedAt: '2026-03-14T10:00:00Z',
|
||||
createdBy: 'demo-user',
|
||||
updatedBy: 'demo-user',
|
||||
tags: ['qa'],
|
||||
}));
|
||||
|
||||
await component.runPreflightChecks();
|
||||
const secondRun = component.preflightChecks().map((check) => ({
|
||||
id: check.id,
|
||||
status: check.status,
|
||||
message: check.message,
|
||||
}));
|
||||
|
||||
expect(secondRun).toEqual(firstRun);
|
||||
component.onIntegrationCreated({
|
||||
name: 'QA Harbor',
|
||||
description: null,
|
||||
type: IntegrationType.Registry,
|
||||
provider: IntegrationProvider.Harbor,
|
||||
endpoint: 'https://harbor.example.com',
|
||||
authRefUri: 'authref://vault/harbor#robot',
|
||||
organizationId: 'platform',
|
||||
extendedConfig: null,
|
||||
tags: ['qa'],
|
||||
});
|
||||
|
||||
it('generates copy-safe Helm deployment template for host onboarding', async () => {
|
||||
await createWizard('host');
|
||||
component.selectProvider('kubernetes');
|
||||
component.selectAuthMethod('helm');
|
||||
component.updateAuthValue('namespace', 'stellaops');
|
||||
component.updateName('Prod Host Observer');
|
||||
|
||||
const template = component.deploymentTemplate();
|
||||
const guidance = component.copySafetyGuidance();
|
||||
|
||||
expect(template).toContain('helm upgrade --install');
|
||||
expect(template).toContain('--namespace stellaops');
|
||||
expect(template).toContain('STELLA_API_TOKEN');
|
||||
expect(guidance.join(' ')).toContain('environment variables');
|
||||
});
|
||||
|
||||
it('generates systemd template with placeholder token and service units', async () => {
|
||||
await createWizard('host');
|
||||
component.selectProvider('vm');
|
||||
component.selectAuthMethod('systemd');
|
||||
component.updateAuthValue('installPath', '/opt/stellaops');
|
||||
component.updateName('VM Host Observer');
|
||||
|
||||
const template = component.deploymentTemplate();
|
||||
|
||||
expect(template).toContain('[Unit]');
|
||||
expect(template).toContain('Environment=STELLA_API_TOKEN=${STELLA_API_TOKEN}');
|
||||
expect(template).toContain('systemctl enable --now zastava-observer');
|
||||
expect(integrationService.create).toHaveBeenCalled();
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/ops/integrations', 'int-42'], {
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user