Widen scratch iteration 011 with fixture-backed integrations QA

This commit is contained in:
master
2026-03-14 03:11:45 +02:00
parent 3b1b7dad80
commit bd78523564
40 changed files with 3478 additions and 2173 deletions

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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());
}
}

View File

@@ -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,
};
}
}

View File

@@ -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',

View File

@@ -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&regions=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;
});

View File

@@ -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&regions=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;
});

View File

@@ -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');
});
});

View File

@@ -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());
},

View File

@@ -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',
});
}
}

View File

@@ -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',
});
});
});

View File

@@ -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 {

View File

@@ -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';
}

View File

@@ -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 },
]);
});
});

View File

@@ -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`);
}
}

View File

@@ -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&#10;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&#10;example/api&#10;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)&#10;e.g., main, release/*"
rows="3"
(input)="parseScopeInput('branches', $any($event.target).value)"
>{{ (draft().scope.branches || []).join('\n') }}</textarea>
placeholder="main&#10;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&#10;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)&#10;e.g., v*, latest, release-*"
rows="3"
(input)="parseScopeInput('tagPatterns', $any($event.target).value)"
>{{ (draft().scope.tagPatterns || []).join('\n') }}</textarea>
placeholder="latest&#10;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)&#10;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>

View File

@@ -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,
}),
}));
});
});

View File

@@ -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.' };
}
}
}

View File

@@ -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;
}
}

View File

@@ -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,
};
}

View File

@@ -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

View File

@@ -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',
});
});
});