Frontend gaps fill work. Testing fixes work. Auditing in progress.

This commit is contained in:
StellaOps Bot
2025-12-30 01:22:58 +02:00
parent 1dc4bcbf10
commit 7a5210e2aa
928 changed files with 183942 additions and 3941 deletions

View File

@@ -0,0 +1,84 @@
# Integrations Module Agent Instructions
## Module Identity
**Module:** Integrations
**Purpose:** Canonical integration catalog for registries, SCM providers, CI systems, repo sources, and runtime hosts.
**Deployable:** `src/Integrations/StellaOps.Integrations.WebService`
---
## Directory Layout
```
src/Integrations/
├── StellaOps.Integrations.WebService/ # ASP.NET Core host
├── __Libraries/
│ ├── StellaOps.Integrations.Core/ # Domain models, enums, events
│ ├── StellaOps.Integrations.Contracts/ # Plugin contracts and DTOs
│ ├── StellaOps.Integrations.Plugins.Abstractions/ # IIntegrationConnectorPlugin
│ ├── StellaOps.Integrations.Persistence/ # PostgreSQL repositories
│ └── StellaOps.Integrations.Testing/ # Shared test fixtures
├── __Plugins/
│ ├── StellaOps.Integrations.Plugin.GitHubApp/
│ ├── StellaOps.Integrations.Plugin.GitLab/
│ ├── StellaOps.Integrations.Plugin.Harbor/
│ ├── StellaOps.Integrations.Plugin.Ecr/
│ ├── StellaOps.Integrations.Plugin.Gcr/
│ ├── StellaOps.Integrations.Plugin.Acr/
│ └── StellaOps.Integrations.Plugin.InMemory/ # Testing / dev
└── __Tests/
└── StellaOps.Integrations.Tests/
```
---
## Roles & Responsibilities
| Role | Expectations |
| --- | --- |
| Backend Engineer | Implement core catalog, plugin loader, API endpoints, event publisher |
| Plugin Author | Implement `IIntegrationConnectorPlugin` for new providers |
| QA Engineer | Cover plugin loading, test-connection, health polling, CRUD scenarios |
| PM/Architect | Keep plugin contracts stable; coordinate cross-module dependencies |
---
## Plugin Contract
All connectors implement `IIntegrationConnectorPlugin : IAvailabilityPlugin`:
```csharp
public interface IIntegrationConnectorPlugin : IAvailabilityPlugin
{
IntegrationType Type { get; }
IntegrationProvider Provider { get; }
Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken ct);
Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken ct);
}
```
---
## Required Reading
- `docs/modules/platform/architecture-overview.md`
- `docs/architecture/integrations.md`
- `docs/modules/authority/architecture.md` (AuthRef handling)
---
## Constraints
1. **No raw secrets:** All credentials use AuthRef URIs resolved at runtime.
2. **Determinism:** Stable ordering in listings; UTC timestamps.
3. **Offline-first:** All plugin test-connection and health must handle network failure gracefully.
4. **Event emission:** Lifecycle events go to Scheduler/Signals via message queue.
---
## Test Coverage
- Unit tests in `__Tests/StellaOps.Integrations.Tests`
- Each plugin has its own test class mocking external APIs
- Integration tests use `StellaOps.Integrations.Plugin.InMemory`

View File

@@ -0,0 +1,27 @@
using StellaOps.Integrations.Core;
namespace StellaOps.Integrations.WebService;
/// <summary>
/// Publishes integration lifecycle events to downstream consumers.
/// </summary>
public interface IIntegrationEventPublisher
{
Task PublishAsync(IntegrationEvent @event, CancellationToken cancellationToken = default);
}
/// <summary>
/// Logs integration audit events.
/// </summary>
public interface IIntegrationAuditLogger
{
Task LogAsync(string action, Guid integrationId, string? userId, object? details, CancellationToken cancellationToken = default);
}
/// <summary>
/// Resolves AuthRef URIs to secret values.
/// </summary>
public interface IAuthRefResolver
{
Task<string?> ResolveAsync(string authRefUri, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,73 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Integrations.Core;
namespace StellaOps.Integrations.WebService.Infrastructure;
/// <summary>
/// Console/log-based event publisher for development and standalone deployments.
/// In production, replace with message queue implementation.
/// </summary>
public sealed class LoggingEventPublisher : IIntegrationEventPublisher
{
private readonly ILogger<LoggingEventPublisher> _logger;
public LoggingEventPublisher(ILogger<LoggingEventPublisher> logger)
{
_logger = logger;
}
public Task PublishAsync(IntegrationEvent @event, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Integration event: {EventType} - {EventJson}",
@event.GetType().Name,
JsonSerializer.Serialize(@event, @event.GetType()));
return Task.CompletedTask;
}
}
/// <summary>
/// Console/log-based audit logger for development and standalone deployments.
/// In production, replace with proper audit store.
/// </summary>
public sealed class LoggingAuditLogger : IIntegrationAuditLogger
{
private readonly ILogger<LoggingAuditLogger> _logger;
public LoggingAuditLogger(ILogger<LoggingAuditLogger> logger)
{
_logger = logger;
}
public Task LogAsync(string action, Guid integrationId, string? userId, object? details, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Audit: {Action} on {IntegrationId} by {UserId} - {Details}",
action,
integrationId,
userId ?? "system",
details is not null ? JsonSerializer.Serialize(details) : "{}");
return Task.CompletedTask;
}
}
/// <summary>
/// Stub AuthRef resolver for development.
/// In production, integrate with Authority service.
/// </summary>
public sealed class StubAuthRefResolver : IAuthRefResolver
{
private readonly ILogger<StubAuthRefResolver> _logger;
public StubAuthRefResolver(ILogger<StubAuthRefResolver> logger)
{
_logger = logger;
}
public Task<string?> ResolveAsync(string authRefUri, CancellationToken cancellationToken = default)
{
_logger.LogWarning("StubAuthRefResolver: Would resolve {AuthRefUri} - returning null in dev mode", authRefUri);
return Task.FromResult<string?>(null);
}
}

View File

@@ -0,0 +1,120 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
namespace StellaOps.Integrations.WebService;
/// <summary>
/// Minimal API endpoints for the Integration Catalog.
/// </summary>
public static class IntegrationEndpoints
{
public static void MapIntegrationEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/integrations")
.WithTags("Integrations")
.WithOpenApi();
// List integrations
group.MapGet("/", async (
[FromServices] IntegrationService service,
[FromQuery] IntegrationType? type,
[FromQuery] IntegrationProvider? provider,
[FromQuery] IntegrationStatus? status,
[FromQuery] string? search,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string sortBy = "name",
[FromQuery] bool sortDescending = false,
CancellationToken cancellationToken = default) =>
{
var query = new ListIntegrationsQuery(type, provider, status, search, null, page, pageSize, sortBy, sortDescending);
var result = await service.ListAsync(query, null, cancellationToken);
return Results.Ok(result);
})
.WithName("ListIntegrations")
.WithDescription("Lists integrations with optional filtering and pagination.");
// Get integration by ID
group.MapGet("/{id:guid}", async (
[FromServices] IntegrationService service,
Guid id,
CancellationToken cancellationToken) =>
{
var result = await service.GetByIdAsync(id, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result);
})
.WithName("GetIntegration")
.WithDescription("Gets an integration by ID.");
// Create integration
group.MapPost("/", async (
[FromServices] IntegrationService service,
[FromBody] CreateIntegrationRequest request,
CancellationToken cancellationToken) =>
{
var result = await service.CreateAsync(request, null, null, cancellationToken);
return Results.Created($"/api/v1/integrations/{result.Id}", result);
})
.WithName("CreateIntegration")
.WithDescription("Creates a new integration.");
// Update integration
group.MapPut("/{id:guid}", async (
[FromServices] IntegrationService service,
Guid id,
[FromBody] UpdateIntegrationRequest request,
CancellationToken cancellationToken) =>
{
var result = await service.UpdateAsync(id, request, null, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result);
})
.WithName("UpdateIntegration")
.WithDescription("Updates an existing integration.");
// Delete integration
group.MapDelete("/{id:guid}", async (
[FromServices] IntegrationService service,
Guid id,
CancellationToken cancellationToken) =>
{
var result = await service.DeleteAsync(id, null, cancellationToken);
return result ? Results.NoContent() : Results.NotFound();
})
.WithName("DeleteIntegration")
.WithDescription("Soft-deletes an integration.");
// Test connection
group.MapPost("/{id:guid}/test", async (
[FromServices] IntegrationService service,
Guid id,
CancellationToken cancellationToken) =>
{
var result = await service.TestConnectionAsync(id, null, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result);
})
.WithName("TestIntegrationConnection")
.WithDescription("Tests connectivity and authentication for an integration.");
// Health check
group.MapGet("/{id:guid}/health", async (
[FromServices] IntegrationService service,
Guid id,
CancellationToken cancellationToken) =>
{
var result = await service.CheckHealthAsync(id, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result);
})
.WithName("CheckIntegrationHealth")
.WithDescription("Performs a health check on an integration.");
// Get supported providers
group.MapGet("/providers", ([FromServices] IntegrationService service) =>
{
var result = service.GetSupportedProviders();
return Results.Ok(result);
})
.WithName("GetSupportedProviders")
.WithDescription("Gets a list of supported integration providers.");
}
}

View File

@@ -0,0 +1,105 @@
using System.Reflection;
using Microsoft.Extensions.Logging;
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using StellaOps.Plugin;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Integrations.WebService;
/// <summary>
/// Loads and manages integration connector plugins.
/// </summary>
public sealed class IntegrationPluginLoader
{
private readonly ILogger<IntegrationPluginLoader>? _logger;
private readonly List<IIntegrationConnectorPlugin> _plugins = [];
public IntegrationPluginLoader(ILogger<IntegrationPluginLoader>? logger = null)
{
_logger = logger;
}
/// <summary>
/// Gets all loaded plugins.
/// </summary>
public IReadOnlyList<IIntegrationConnectorPlugin> Plugins => _plugins;
/// <summary>
/// Discovers and loads integration connector plugins from the specified directory.
/// </summary>
public IReadOnlyList<IIntegrationConnectorPlugin> LoadFromDirectory(
string pluginDirectory,
string searchPattern = "StellaOps.Integrations.Plugin.*.dll")
{
if (!Directory.Exists(pluginDirectory))
{
_logger?.LogWarning("Plugin directory does not exist: {Directory}", pluginDirectory);
return [];
}
var options = new PluginHostOptions
{
PluginsDirectory = pluginDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = false
};
options.SearchPatterns.Add(searchPattern);
var result = PluginHost.LoadPlugins(options);
var loadedPlugins = new List<IIntegrationConnectorPlugin>();
foreach (var pluginAssembly in result.Plugins)
{
var connectorPlugins = PluginLoader.LoadPlugins<IIntegrationConnectorPlugin>(new[] { pluginAssembly.Assembly });
loadedPlugins.AddRange(connectorPlugins);
foreach (var plugin in connectorPlugins)
{
_logger?.LogDebug("Loaded integration connector plugin: {Name} ({Provider}) from {Assembly}",
plugin.Name, plugin.Provider, pluginAssembly.Assembly.GetName().Name);
}
}
_plugins.AddRange(loadedPlugins);
return loadedPlugins;
}
/// <summary>
/// Loads integration connector plugins from the specified assemblies.
/// </summary>
public IReadOnlyList<IIntegrationConnectorPlugin> LoadFromAssemblies(IEnumerable<Assembly> assemblies)
{
var loadedPlugins = PluginLoader.LoadPlugins<IIntegrationConnectorPlugin>(assemblies);
_plugins.AddRange(loadedPlugins);
return loadedPlugins;
}
/// <summary>
/// Gets a plugin by provider.
/// </summary>
public IIntegrationConnectorPlugin? GetByProvider(IntegrationProvider provider)
{
return _plugins.FirstOrDefault(p => p.Provider == provider);
}
/// <summary>
/// Gets all plugins for a given type.
/// </summary>
public IReadOnlyList<IIntegrationConnectorPlugin> GetByType(IntegrationType type)
{
return _plugins.Where(p => p.Type == type).ToList();
}
/// <summary>
/// Gets all available plugins (checking IsAvailable).
/// </summary>
public IReadOnlyList<IIntegrationConnectorPlugin> GetAvailable(IServiceProvider services)
{
return _plugins.Where(p =>
{
try { return p.IsAvailable(services); }
catch { return false; }
}).ToList();
}
}

View File

@@ -0,0 +1,316 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using StellaOps.Integrations.Persistence;
namespace StellaOps.Integrations.WebService;
/// <summary>
/// Core service for integration catalog operations.
/// </summary>
public sealed class IntegrationService
{
private readonly IIntegrationRepository _repository;
private readonly IntegrationPluginLoader _pluginLoader;
private readonly IIntegrationEventPublisher _eventPublisher;
private readonly IIntegrationAuditLogger _auditLogger;
private readonly IAuthRefResolver _authRefResolver;
private readonly ILogger<IntegrationService> _logger;
public IntegrationService(
IIntegrationRepository repository,
IntegrationPluginLoader pluginLoader,
IIntegrationEventPublisher eventPublisher,
IIntegrationAuditLogger auditLogger,
IAuthRefResolver authRefResolver,
ILogger<IntegrationService> logger)
{
_repository = repository;
_pluginLoader = pluginLoader;
_eventPublisher = eventPublisher;
_auditLogger = auditLogger;
_authRefResolver = authRefResolver;
_logger = logger;
}
public async Task<IntegrationResponse> CreateAsync(CreateIntegrationRequest request, string? userId, string? tenantId, CancellationToken cancellationToken = default)
{
var integration = new Integration
{
Id = Guid.NewGuid(),
Name = request.Name,
Description = request.Description,
Type = request.Type,
Provider = request.Provider,
Status = IntegrationStatus.Pending,
Endpoint = request.Endpoint,
AuthRefUri = request.AuthRefUri,
OrganizationId = request.OrganizationId,
ConfigJson = request.ExtendedConfig is not null ? JsonSerializer.Serialize(request.ExtendedConfig) : null,
Tags = request.Tags?.ToList() ?? [],
CreatedBy = userId,
UpdatedBy = userId,
TenantId = tenantId
};
var created = await _repository.CreateAsync(integration, cancellationToken);
await _eventPublisher.PublishAsync(new IntegrationCreatedEvent(
created.Id,
created.Name,
created.Type,
created.Provider,
userId,
DateTimeOffset.UtcNow), cancellationToken);
await _auditLogger.LogAsync("integration.created", created.Id, userId, new { created.Name, created.Type, created.Provider }, cancellationToken);
_logger.LogInformation("Integration created: {Id} ({Name}) by {User}", created.Id, created.Name, userId);
return MapToResponse(created);
}
public async Task<IntegrationResponse?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
var integration = await _repository.GetByIdAsync(id, cancellationToken);
return integration is null ? null : MapToResponse(integration);
}
public async Task<PagedIntegrationsResponse> ListAsync(ListIntegrationsQuery query, string? tenantId, CancellationToken cancellationToken = default)
{
var repoQuery = new IntegrationQuery(
Type: query.Type,
Provider: query.Provider,
Status: query.Status,
Search: query.Search,
Tags: query.Tags,
TenantId: tenantId,
Skip: (query.Page - 1) * query.PageSize,
Take: query.PageSize,
SortBy: query.SortBy,
SortDescending: query.SortDescending);
var integrations = await _repository.GetAllAsync(repoQuery, cancellationToken);
var totalCount = await _repository.CountAsync(repoQuery, cancellationToken);
var totalPages = (int)Math.Ceiling(totalCount / (double)query.PageSize);
return new PagedIntegrationsResponse(
integrations.Select(MapToResponse).ToList(),
totalCount,
query.Page,
query.PageSize,
totalPages);
}
public async Task<IntegrationResponse?> UpdateAsync(Guid id, UpdateIntegrationRequest request, string? userId, CancellationToken cancellationToken = default)
{
var integration = await _repository.GetByIdAsync(id, cancellationToken);
if (integration is null) return null;
var oldStatus = integration.Status;
if (request.Name is not null) integration.Name = request.Name;
if (request.Description is not null) integration.Description = request.Description;
if (request.Endpoint is not null) integration.Endpoint = request.Endpoint;
if (request.AuthRefUri is not null) integration.AuthRefUri = request.AuthRefUri;
if (request.OrganizationId is not null) integration.OrganizationId = request.OrganizationId;
if (request.ExtendedConfig is not null) integration.ConfigJson = JsonSerializer.Serialize(request.ExtendedConfig);
if (request.Tags is not null) integration.Tags = request.Tags.ToList();
if (request.Status.HasValue) integration.Status = request.Status.Value;
integration.UpdatedAt = DateTimeOffset.UtcNow;
integration.UpdatedBy = userId;
var updated = await _repository.UpdateAsync(integration, cancellationToken);
await _eventPublisher.PublishAsync(new IntegrationUpdatedEvent(
updated.Id,
updated.Name,
userId,
DateTimeOffset.UtcNow), cancellationToken);
if (oldStatus != updated.Status)
{
await _eventPublisher.PublishAsync(new IntegrationStatusChangedEvent(
updated.Id,
oldStatus,
updated.Status,
DateTimeOffset.UtcNow), cancellationToken);
}
await _auditLogger.LogAsync("integration.updated", updated.Id, userId, new { updated.Name, OldStatus = oldStatus, NewStatus = updated.Status }, cancellationToken);
_logger.LogInformation("Integration updated: {Id} ({Name}) by {User}", updated.Id, updated.Name, userId);
return MapToResponse(updated);
}
public async Task<bool> DeleteAsync(Guid id, string? userId, CancellationToken cancellationToken = default)
{
var integration = await _repository.GetByIdAsync(id, cancellationToken);
if (integration is null) return false;
await _repository.DeleteAsync(id, cancellationToken);
await _eventPublisher.PublishAsync(new IntegrationDeletedEvent(
id,
userId,
DateTimeOffset.UtcNow), cancellationToken);
await _auditLogger.LogAsync("integration.deleted", id, userId, new { integration.Name }, cancellationToken);
_logger.LogInformation("Integration deleted: {Id} ({Name}) by {User}", id, integration.Name, userId);
return true;
}
public async Task<TestConnectionResponse?> TestConnectionAsync(Guid id, string? userId, CancellationToken cancellationToken = default)
{
var integration = await _repository.GetByIdAsync(id, cancellationToken);
if (integration is null) return null;
var plugin = _pluginLoader.GetByProvider(integration.Provider);
if (plugin is null)
{
_logger.LogWarning("No plugin found for provider {Provider}", integration.Provider);
return new TestConnectionResponse(
id,
false,
$"No connector plugin available for provider {integration.Provider}",
null,
TimeSpan.Zero,
DateTimeOffset.UtcNow);
}
var resolvedSecret = integration.AuthRefUri is not null
? await _authRefResolver.ResolveAsync(integration.AuthRefUri, cancellationToken)
: null;
var config = BuildConfig(integration, resolvedSecret);
var startTime = DateTimeOffset.UtcNow;
var result = await plugin.TestConnectionAsync(config, cancellationToken);
var endTime = DateTimeOffset.UtcNow;
// Update integration status based on result
var newStatus = result.Success ? IntegrationStatus.Active : IntegrationStatus.Failed;
if (integration.Status != newStatus)
{
var oldStatus = integration.Status;
integration.Status = newStatus;
integration.UpdatedAt = endTime;
await _repository.UpdateAsync(integration, cancellationToken);
await _eventPublisher.PublishAsync(new IntegrationStatusChangedEvent(
id, oldStatus, newStatus, endTime), cancellationToken);
}
await _eventPublisher.PublishAsync(new IntegrationTestConnectionEvent(
id, result.Success, result.Message, endTime), cancellationToken);
await _auditLogger.LogAsync("integration.test_connection", id, userId, new { result.Success, result.Message }, cancellationToken);
return new TestConnectionResponse(
id,
result.Success,
result.Message,
result.Details,
result.Duration,
endTime);
}
public async Task<HealthCheckResponse?> CheckHealthAsync(Guid id, CancellationToken cancellationToken = default)
{
var integration = await _repository.GetByIdAsync(id, cancellationToken);
if (integration is null) return null;
var plugin = _pluginLoader.GetByProvider(integration.Provider);
if (plugin is null)
{
return new HealthCheckResponse(
id,
HealthStatus.Unknown,
$"No connector plugin available for provider {integration.Provider}",
null,
DateTimeOffset.UtcNow,
TimeSpan.Zero);
}
var resolvedSecret = integration.AuthRefUri is not null
? await _authRefResolver.ResolveAsync(integration.AuthRefUri, cancellationToken)
: null;
var config = BuildConfig(integration, resolvedSecret);
var result = await plugin.CheckHealthAsync(config, cancellationToken);
var oldHealth = integration.LastHealthStatus;
if (oldHealth != result.Status)
{
await _repository.UpdateHealthStatusAsync(id, result.Status, result.CheckedAt, cancellationToken);
await _eventPublisher.PublishAsync(new IntegrationHealthChangedEvent(
id, oldHealth, result.Status, result.CheckedAt), cancellationToken);
}
return new HealthCheckResponse(
id,
result.Status,
result.Message,
result.Details,
result.CheckedAt,
result.Duration);
}
public IReadOnlyList<ProviderInfo> GetSupportedProviders()
{
return _pluginLoader.Plugins.Select(p => new ProviderInfo(
p.Name,
p.Type,
p.Provider)).ToList();
}
private static IntegrationConfig BuildConfig(Integration integration, string? resolvedSecret)
{
IReadOnlyDictionary<string, object>? extendedConfig = null;
if (!string.IsNullOrEmpty(integration.ConfigJson))
{
extendedConfig = JsonSerializer.Deserialize<Dictionary<string, object>>(integration.ConfigJson);
}
return new IntegrationConfig(
integration.Id,
integration.Type,
integration.Provider,
integration.Endpoint,
resolvedSecret,
integration.OrganizationId,
extendedConfig);
}
private static IntegrationResponse MapToResponse(Integration integration)
{
return new IntegrationResponse(
integration.Id,
integration.Name,
integration.Description,
integration.Type,
integration.Provider,
integration.Status,
integration.Endpoint,
integration.AuthRefUri is not null,
integration.OrganizationId,
integration.LastHealthStatus,
integration.LastHealthCheckAt,
integration.CreatedAt,
integration.UpdatedAt,
integration.CreatedBy,
integration.UpdatedBy,
integration.Tags);
}
}
/// <summary>
/// Information about a supported provider.
/// </summary>
public sealed record ProviderInfo(string Name, IntegrationType Type, IntegrationProvider Provider);

View File

@@ -0,0 +1,94 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Integrations.Persistence;
using StellaOps.Integrations.WebService;
using StellaOps.Integrations.WebService.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new() { Title = "StellaOps Integration Catalog API", Version = "v1" });
});
// Database
var connectionString = builder.Configuration.GetConnectionString("IntegrationsDb")
?? "Host=localhost;Database=stellaops_integrations;Username=postgres;Password=postgres";
builder.Services.AddDbContext<IntegrationDbContext>(options =>
options.UseNpgsql(connectionString));
// Repository
builder.Services.AddScoped<IIntegrationRepository, PostgresIntegrationRepository>();
// Plugin loader
builder.Services.AddSingleton<IntegrationPluginLoader>(sp =>
{
var logger = sp.GetRequiredService<ILogger<IntegrationPluginLoader>>();
var loader = new IntegrationPluginLoader(logger);
// Load from plugins directory
var pluginsDir = builder.Configuration.GetValue<string>("Integrations:PluginsDirectory")
?? Path.Combine(AppContext.BaseDirectory, "plugins");
if (Directory.Exists(pluginsDir))
{
loader.LoadFromDirectory(pluginsDir);
}
// Also load from current assembly (for built-in plugins)
loader.LoadFromAssemblies([typeof(Program).Assembly]);
return loader;
});
// Infrastructure
builder.Services.AddScoped<IIntegrationEventPublisher, LoggingEventPublisher>();
builder.Services.AddScoped<IIntegrationAuditLogger, LoggingAuditLogger>();
builder.Services.AddScoped<IAuthRefResolver, StubAuthRefResolver>();
// Core service
builder.Services.AddScoped<IntegrationService>();
// CORS for Angular dev server
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins("http://localhost:4200", "https://localhost:4200")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
var app = builder.Build();
// Configure pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors();
// Map endpoints
app.MapIntegrationEndpoints();
// Health endpoint
app.MapGet("/health", () => Results.Ok(new { Status = "Healthy", Timestamp = DateTimeOffset.UtcNow }))
.WithTags("Health")
.WithName("HealthCheck");
// Ensure database is created (dev only)
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<IntegrationDbContext>();
await dbContext.Database.EnsureCreatedAsync();
}
app.Run();
public partial class Program { }

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Integrations.WebService</RootNamespace>
<AssemblyName>StellaOps.Integrations.WebService</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\__Libraries\StellaOps.Integrations.Core\StellaOps.Integrations.Core.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Integrations.Persistence\StellaOps.Integrations.Persistence.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,15 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
},
"ConnectionStrings": {
"IntegrationsDb": "Host=localhost;Database=stellaops_integrations_dev;Username=postgres;Password=postgres"
},
"Integrations": {
"PluginsDirectory": "plugins"
}
}

View File

@@ -0,0 +1,16 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"IntegrationsDb": "Host=localhost;Database=stellaops_integrations;Username=postgres;Password=postgres"
},
"Integrations": {
"PluginsDirectory": "plugins"
}
}

View File

@@ -0,0 +1,37 @@
using StellaOps.Integrations.Core;
using StellaOps.Plugin;
namespace StellaOps.Integrations.Contracts;
/// <summary>
/// Plugin contract for integration connectors.
/// Each provider (GitHub, Harbor, ECR, etc.) implements this interface.
/// </summary>
public interface IIntegrationConnectorPlugin : IAvailabilityPlugin
{
/// <summary>
/// Integration type this plugin handles.
/// </summary>
IntegrationType Type { get; }
/// <summary>
/// Specific provider implementation.
/// </summary>
IntegrationProvider Provider { get; }
/// <summary>
/// Tests connectivity and authentication to the integration endpoint.
/// </summary>
/// <param name="config">Configuration including resolved secrets.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result indicating success or failure with details.</returns>
Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default);
/// <summary>
/// Performs a health check on the integration endpoint.
/// </summary>
/// <param name="config">Configuration including resolved secrets.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Health check result with status and details.</returns>
Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,97 @@
using StellaOps.Integrations.Core;
namespace StellaOps.Integrations.Contracts;
/// <summary>
/// Request DTO for creating an integration.
/// </summary>
public sealed record CreateIntegrationRequest(
string Name,
string? Description,
IntegrationType Type,
IntegrationProvider Provider,
string Endpoint,
string? AuthRefUri,
string? OrganizationId,
IReadOnlyDictionary<string, object>? ExtendedConfig,
IReadOnlyList<string>? Tags);
/// <summary>
/// Request DTO for updating an integration.
/// </summary>
public sealed record UpdateIntegrationRequest(
string? Name,
string? Description,
string? Endpoint,
string? AuthRefUri,
string? OrganizationId,
IReadOnlyDictionary<string, object>? ExtendedConfig,
IReadOnlyList<string>? Tags,
IntegrationStatus? Status);
/// <summary>
/// Response DTO for integration details.
/// </summary>
public sealed record IntegrationResponse(
Guid Id,
string Name,
string? Description,
IntegrationType Type,
IntegrationProvider Provider,
IntegrationStatus Status,
string Endpoint,
bool HasAuth,
string? OrganizationId,
HealthStatus LastHealthStatus,
DateTimeOffset? LastHealthCheckAt,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
string? CreatedBy,
string? UpdatedBy,
IReadOnlyList<string> Tags);
/// <summary>
/// Response DTO for test-connection operation.
/// </summary>
public sealed record TestConnectionResponse(
Guid IntegrationId,
bool Success,
string? Message,
IReadOnlyDictionary<string, string>? Details,
TimeSpan Duration,
DateTimeOffset TestedAt);
/// <summary>
/// Response DTO for health check operation.
/// </summary>
public sealed record HealthCheckResponse(
Guid IntegrationId,
HealthStatus Status,
string? Message,
IReadOnlyDictionary<string, string>? Details,
DateTimeOffset CheckedAt,
TimeSpan Duration);
/// <summary>
/// Query parameters for listing integrations.
/// </summary>
public sealed record ListIntegrationsQuery(
IntegrationType? Type = null,
IntegrationProvider? Provider = null,
IntegrationStatus? Status = null,
string? Search = null,
IReadOnlyList<string>? Tags = null,
int Page = 1,
int PageSize = 20,
string SortBy = "name",
bool SortDescending = false);
/// <summary>
/// Paginated response for integration listings.
/// </summary>
public sealed record PagedIntegrationsResponse(
IReadOnlyList<IntegrationResponse> Items,
int TotalCount,
int Page,
int PageSize,
int TotalPages);

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Integrations.Contracts</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Integrations.Core\StellaOps.Integrations.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,100 @@
namespace StellaOps.Integrations.Core;
/// <summary>
/// Core domain entity representing a configured integration.
/// </summary>
public sealed class Integration
{
public required Guid Id { get; init; }
/// <summary>
/// Human-readable name for the integration.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Optional description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Classification of integration by functional purpose.
/// </summary>
public required IntegrationType Type { get; init; }
/// <summary>
/// Specific provider implementation.
/// </summary>
public required IntegrationProvider Provider { get; init; }
/// <summary>
/// Lifecycle status.
/// </summary>
public IntegrationStatus Status { get; set; } = IntegrationStatus.Pending;
/// <summary>
/// Base URL or endpoint for the integration.
/// </summary>
public required string Endpoint { get; set; }
/// <summary>
/// Reference to stored credentials (AuthRef URI, never raw secret).
/// Format: authref://{vault}/{path}#{key} or similar.
/// </summary>
public string? AuthRefUri { get; set; }
/// <summary>
/// Organization or tenant identifier within the provider.
/// </summary>
public string? OrganizationId { get; set; }
/// <summary>
/// Additional provider-specific configuration as JSON.
/// </summary>
public string? ConfigJson { get; set; }
/// <summary>
/// Last health check result.
/// </summary>
public HealthStatus LastHealthStatus { get; set; } = HealthStatus.Unknown;
/// <summary>
/// UTC timestamp of last health check.
/// </summary>
public DateTimeOffset? LastHealthCheckAt { get; set; }
/// <summary>
/// UTC timestamp when the integration was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// UTC timestamp when the integration was last updated.
/// </summary>
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
/// <summary>
/// User or system that created this integration.
/// </summary>
public string? CreatedBy { get; init; }
/// <summary>
/// User or system that last modified this integration.
/// </summary>
public string? UpdatedBy { get; set; }
/// <summary>
/// Tenant/workspace isolation identifier.
/// </summary>
public string? TenantId { get; init; }
/// <summary>
/// Tags for filtering and grouping.
/// </summary>
public List<string> Tags { get; set; } = [];
/// <summary>
/// Soft-delete marker.
/// </summary>
public bool IsDeleted { get; set; }
}

View File

@@ -0,0 +1,113 @@
namespace StellaOps.Integrations.Core;
/// <summary>
/// Classification of integration by functional purpose.
/// </summary>
public enum IntegrationType
{
/// <summary>Container registry (Harbor, ECR, GCR, ACR, Docker Hub, etc.).</summary>
Registry = 1,
/// <summary>Source code management (GitHub, GitLab, Bitbucket, Gitea, etc.).</summary>
Scm = 2,
/// <summary>CI/CD system (GitHub Actions, GitLab CI, Jenkins, etc.).</summary>
CiCd = 3,
/// <summary>Repository source for packages (npm, PyPI, Maven, NuGet, etc.).</summary>
RepoSource = 4,
/// <summary>Runtime host for telemetry (eBPF, ETW, dyld, etc.).</summary>
RuntimeHost = 5,
/// <summary>Advisory/vulnerability feed mirror.</summary>
FeedMirror = 6
}
/// <summary>
/// Specific provider implementation within an integration type.
/// </summary>
public enum IntegrationProvider
{
// Registry providers
Harbor = 100,
Ecr = 101,
Gcr = 102,
Acr = 103,
DockerHub = 104,
Quay = 105,
Artifactory = 106,
Nexus = 107,
GitHubContainerRegistry = 108,
GitLabContainerRegistry = 109,
// SCM providers
GitHubApp = 200,
GitLabServer = 201,
Bitbucket = 202,
Gitea = 203,
AzureDevOps = 204,
// CI/CD providers
GitHubActions = 300,
GitLabCi = 301,
Jenkins = 302,
CircleCi = 303,
AzurePipelines = 304,
ArgoWorkflows = 305,
Tekton = 306,
// Repo sources
NpmRegistry = 400,
PyPi = 401,
MavenCentral = 402,
NuGetOrg = 403,
CratesIo = 404,
GoProxy = 405,
// Runtime hosts
EbpfAgent = 500,
EtwAgent = 501,
DyldInterposer = 502,
// Feed mirrors
StellaOpsMirror = 600,
NvdMirror = 601,
OsvMirror = 602,
// Generic / testing
InMemory = 900,
Custom = 999
}
/// <summary>
/// Lifecycle status of an integration instance.
/// </summary>
public enum IntegrationStatus
{
/// <summary>Just created, not yet tested.</summary>
Pending = 0,
/// <summary>Connection test passed.</summary>
Active = 1,
/// <summary>Connection test failed.</summary>
Failed = 2,
/// <summary>Administratively disabled.</summary>
Disabled = 3,
/// <summary>Marked for deletion.</summary>
Archived = 4
}
/// <summary>
/// Health check result status.
/// </summary>
public enum HealthStatus
{
Unknown = 0,
Healthy = 1,
Degraded = 2,
Unhealthy = 3
}

View File

@@ -0,0 +1,74 @@
namespace StellaOps.Integrations.Core;
/// <summary>
/// Configuration passed to connector plugins for test-connection and health checks.
/// </summary>
public sealed record IntegrationConfig(
Guid IntegrationId,
IntegrationType Type,
IntegrationProvider Provider,
string Endpoint,
string? ResolvedSecret,
string? OrganizationId,
IReadOnlyDictionary<string, object>? ExtendedConfig);
/// <summary>
/// Result of a test-connection operation.
/// </summary>
public sealed record TestConnectionResult(
bool Success,
string? Message,
IReadOnlyDictionary<string, string>? Details,
TimeSpan Duration);
/// <summary>
/// Result of a health check operation.
/// </summary>
public sealed record HealthCheckResult(
HealthStatus Status,
string? Message,
IReadOnlyDictionary<string, string>? Details,
DateTimeOffset CheckedAt,
TimeSpan Duration);
/// <summary>
/// Integration lifecycle events for downstream consumers.
/// </summary>
public abstract record IntegrationEvent(Guid IntegrationId, DateTimeOffset OccurredAt);
public sealed record IntegrationCreatedEvent(
Guid IntegrationId,
string Name,
IntegrationType Type,
IntegrationProvider Provider,
string? CreatedBy,
DateTimeOffset OccurredAt) : IntegrationEvent(IntegrationId, OccurredAt);
public sealed record IntegrationUpdatedEvent(
Guid IntegrationId,
string Name,
string? UpdatedBy,
DateTimeOffset OccurredAt) : IntegrationEvent(IntegrationId, OccurredAt);
public sealed record IntegrationDeletedEvent(
Guid IntegrationId,
string? DeletedBy,
DateTimeOffset OccurredAt) : IntegrationEvent(IntegrationId, OccurredAt);
public sealed record IntegrationStatusChangedEvent(
Guid IntegrationId,
IntegrationStatus OldStatus,
IntegrationStatus NewStatus,
DateTimeOffset OccurredAt) : IntegrationEvent(IntegrationId, OccurredAt);
public sealed record IntegrationHealthChangedEvent(
Guid IntegrationId,
HealthStatus OldHealth,
HealthStatus NewHealth,
DateTimeOffset OccurredAt) : IntegrationEvent(IntegrationId, OccurredAt);
public sealed record IntegrationTestConnectionEvent(
Guid IntegrationId,
bool Success,
string? Message,
DateTimeOffset OccurredAt) : IntegrationEvent(IntegrationId, OccurredAt);

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Integrations.Core</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,35 @@
using StellaOps.Integrations.Core;
namespace StellaOps.Integrations.Persistence;
/// <summary>
/// Repository contract for integration persistence.
/// </summary>
public interface IIntegrationRepository
{
Task<Integration?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<Integration>> GetAllAsync(IntegrationQuery query, CancellationToken cancellationToken = default);
Task<int> CountAsync(IntegrationQuery query, CancellationToken cancellationToken = default);
Task<Integration> CreateAsync(Integration integration, CancellationToken cancellationToken = default);
Task<Integration> UpdateAsync(Integration integration, CancellationToken cancellationToken = default);
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<Integration>> GetByProviderAsync(IntegrationProvider provider, CancellationToken cancellationToken = default);
Task<IReadOnlyList<Integration>> GetActiveByTypeAsync(IntegrationType type, CancellationToken cancellationToken = default);
Task UpdateHealthStatusAsync(Guid id, HealthStatus status, DateTimeOffset checkedAt, CancellationToken cancellationToken = default);
}
/// <summary>
/// Query parameters for repository operations.
/// </summary>
public sealed record IntegrationQuery(
IntegrationType? Type = null,
IntegrationProvider? Provider = null,
IntegrationStatus? Status = null,
string? Search = null,
IReadOnlyList<string>? Tags = null,
string? TenantId = null,
bool IncludeDeleted = false,
int Skip = 0,
int Take = 20,
string SortBy = "name",
bool SortDescending = false);

View File

@@ -0,0 +1,83 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Integrations.Core;
namespace StellaOps.Integrations.Persistence;
/// <summary>
/// EF Core DbContext for Integration persistence.
/// </summary>
public sealed class IntegrationDbContext : DbContext
{
public IntegrationDbContext(DbContextOptions<IntegrationDbContext> options)
: base(options)
{
}
public DbSet<IntegrationEntity> Integrations => Set<IntegrationEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<IntegrationEntity>(entity =>
{
entity.ToTable("integrations");
entity.HasKey(e => e.Id);
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.Name).HasColumnName("name").HasMaxLength(256).IsRequired();
entity.Property(e => e.Description).HasColumnName("description").HasMaxLength(1024);
entity.Property(e => e.Type).HasColumnName("type").IsRequired();
entity.Property(e => e.Provider).HasColumnName("provider").IsRequired();
entity.Property(e => e.Status).HasColumnName("status").IsRequired();
entity.Property(e => e.Endpoint).HasColumnName("endpoint").HasMaxLength(2048).IsRequired();
entity.Property(e => e.AuthRefUri).HasColumnName("auth_ref_uri").HasMaxLength(1024);
entity.Property(e => e.OrganizationId).HasColumnName("organization_id").HasMaxLength(256);
entity.Property(e => e.ConfigJson).HasColumnName("config_json").HasColumnType("jsonb");
entity.Property(e => e.LastHealthStatus).HasColumnName("last_health_status");
entity.Property(e => e.LastHealthCheckAt).HasColumnName("last_health_check_at");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").IsRequired();
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at").IsRequired();
entity.Property(e => e.CreatedBy).HasColumnName("created_by").HasMaxLength(256);
entity.Property(e => e.UpdatedBy).HasColumnName("updated_by").HasMaxLength(256);
entity.Property(e => e.TenantId).HasColumnName("tenant_id").HasMaxLength(128);
entity.Property(e => e.TagsJson).HasColumnName("tags").HasColumnType("jsonb");
entity.Property(e => e.IsDeleted).HasColumnName("is_deleted").IsRequired();
entity.HasIndex(e => e.Type);
entity.HasIndex(e => e.Provider);
entity.HasIndex(e => e.Status);
entity.HasIndex(e => e.TenantId);
entity.HasIndex(e => new { e.TenantId, e.Name }).IsUnique().HasFilter("is_deleted = false");
});
}
}
/// <summary>
/// EF Core entity for Integration.
/// </summary>
public sealed class IntegrationEntity
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public IntegrationType Type { get; set; }
public IntegrationProvider Provider { get; set; }
public IntegrationStatus Status { get; set; }
public string Endpoint { get; set; } = string.Empty;
public string? AuthRefUri { get; set; }
public string? OrganizationId { get; set; }
public string? ConfigJson { get; set; }
public HealthStatus LastHealthStatus { get; set; }
public DateTimeOffset? LastHealthCheckAt { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? CreatedBy { get; set; }
public string? UpdatedBy { get; set; }
public string? TenantId { get; set; }
public string? TagsJson { get; set; }
public bool IsDeleted { get; set; }
}

View File

@@ -0,0 +1,229 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using StellaOps.Integrations.Core;
namespace StellaOps.Integrations.Persistence;
/// <summary>
/// PostgreSQL implementation of integration repository.
/// </summary>
public sealed class PostgresIntegrationRepository : IIntegrationRepository
{
private readonly IntegrationDbContext _context;
public PostgresIntegrationRepository(IntegrationDbContext context)
{
_context = context;
}
public async Task<Integration?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
var entity = await _context.Integrations
.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == id && !e.IsDeleted, cancellationToken);
return entity is null ? null : MapToDomain(entity);
}
public async Task<IReadOnlyList<Integration>> GetAllAsync(IntegrationQuery query, CancellationToken cancellationToken = default)
{
var dbQuery = BuildQuery(query);
dbQuery = query.SortBy.ToLowerInvariant() switch
{
"name" => query.SortDescending ? dbQuery.OrderByDescending(e => e.Name) : dbQuery.OrderBy(e => e.Name),
"createdat" => query.SortDescending ? dbQuery.OrderByDescending(e => e.CreatedAt) : dbQuery.OrderBy(e => e.CreatedAt),
"updatedat" => query.SortDescending ? dbQuery.OrderByDescending(e => e.UpdatedAt) : dbQuery.OrderBy(e => e.UpdatedAt),
"status" => query.SortDescending ? dbQuery.OrderByDescending(e => e.Status) : dbQuery.OrderBy(e => e.Status),
_ => dbQuery.OrderBy(e => e.Name)
};
var entities = await dbQuery
.Skip(query.Skip)
.Take(query.Take)
.AsNoTracking()
.ToListAsync(cancellationToken);
return entities.Select(MapToDomain).ToList();
}
public async Task<int> CountAsync(IntegrationQuery query, CancellationToken cancellationToken = default)
{
return await BuildQuery(query).CountAsync(cancellationToken);
}
public async Task<Integration> CreateAsync(Integration integration, CancellationToken cancellationToken = default)
{
var entity = MapToEntity(integration);
_context.Integrations.Add(entity);
await _context.SaveChangesAsync(cancellationToken);
return MapToDomain(entity);
}
public async Task<Integration> UpdateAsync(Integration integration, CancellationToken cancellationToken = default)
{
var entity = await _context.Integrations
.FirstOrDefaultAsync(e => e.Id == integration.Id, cancellationToken)
?? throw new InvalidOperationException($"Integration {integration.Id} not found");
entity.Name = integration.Name;
entity.Description = integration.Description;
entity.Status = integration.Status;
entity.Endpoint = integration.Endpoint;
entity.AuthRefUri = integration.AuthRefUri;
entity.OrganizationId = integration.OrganizationId;
entity.ConfigJson = integration.ConfigJson;
entity.LastHealthStatus = integration.LastHealthStatus;
entity.LastHealthCheckAt = integration.LastHealthCheckAt;
entity.UpdatedAt = integration.UpdatedAt;
entity.UpdatedBy = integration.UpdatedBy;
entity.TagsJson = integration.Tags.Count > 0 ? JsonSerializer.Serialize(integration.Tags) : null;
entity.IsDeleted = integration.IsDeleted;
await _context.SaveChangesAsync(cancellationToken);
return MapToDomain(entity);
}
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
{
var entity = await _context.Integrations
.FirstOrDefaultAsync(e => e.Id == id, cancellationToken);
if (entity is not null)
{
entity.IsDeleted = true;
entity.Status = IntegrationStatus.Archived;
entity.UpdatedAt = DateTimeOffset.UtcNow;
await _context.SaveChangesAsync(cancellationToken);
}
}
public async Task<IReadOnlyList<Integration>> GetByProviderAsync(IntegrationProvider provider, CancellationToken cancellationToken = default)
{
var entities = await _context.Integrations
.Where(e => e.Provider == provider && !e.IsDeleted)
.AsNoTracking()
.ToListAsync(cancellationToken);
return entities.Select(MapToDomain).ToList();
}
public async Task<IReadOnlyList<Integration>> GetActiveByTypeAsync(IntegrationType type, CancellationToken cancellationToken = default)
{
var entities = await _context.Integrations
.Where(e => e.Type == type && e.Status == IntegrationStatus.Active && !e.IsDeleted)
.AsNoTracking()
.ToListAsync(cancellationToken);
return entities.Select(MapToDomain).ToList();
}
public async Task UpdateHealthStatusAsync(Guid id, HealthStatus status, DateTimeOffset checkedAt, CancellationToken cancellationToken = default)
{
var entity = await _context.Integrations
.FirstOrDefaultAsync(e => e.Id == id, cancellationToken);
if (entity is not null)
{
entity.LastHealthStatus = status;
entity.LastHealthCheckAt = checkedAt;
await _context.SaveChangesAsync(cancellationToken);
}
}
private IQueryable<IntegrationEntity> BuildQuery(IntegrationQuery query)
{
var dbQuery = _context.Integrations.AsQueryable();
if (!query.IncludeDeleted)
{
dbQuery = dbQuery.Where(e => !e.IsDeleted);
}
if (query.TenantId is not null)
{
dbQuery = dbQuery.Where(e => e.TenantId == query.TenantId);
}
if (query.Type.HasValue)
{
dbQuery = dbQuery.Where(e => e.Type == query.Type.Value);
}
if (query.Provider.HasValue)
{
dbQuery = dbQuery.Where(e => e.Provider == query.Provider.Value);
}
if (query.Status.HasValue)
{
dbQuery = dbQuery.Where(e => e.Status == query.Status.Value);
}
if (!string.IsNullOrWhiteSpace(query.Search))
{
var searchLower = query.Search.ToLowerInvariant();
dbQuery = dbQuery.Where(e =>
e.Name.ToLower().Contains(searchLower) ||
(e.Description != null && e.Description.ToLower().Contains(searchLower)));
}
return dbQuery;
}
private static Integration MapToDomain(IntegrationEntity entity)
{
var tags = string.IsNullOrEmpty(entity.TagsJson)
? new List<string>()
: JsonSerializer.Deserialize<List<string>>(entity.TagsJson) ?? new List<string>();
return new Integration
{
Id = entity.Id,
Name = entity.Name,
Description = entity.Description,
Type = entity.Type,
Provider = entity.Provider,
Status = entity.Status,
Endpoint = entity.Endpoint,
AuthRefUri = entity.AuthRefUri,
OrganizationId = entity.OrganizationId,
ConfigJson = entity.ConfigJson,
LastHealthStatus = entity.LastHealthStatus,
LastHealthCheckAt = entity.LastHealthCheckAt,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
CreatedBy = entity.CreatedBy,
UpdatedBy = entity.UpdatedBy,
TenantId = entity.TenantId,
Tags = tags,
IsDeleted = entity.IsDeleted
};
}
private static IntegrationEntity MapToEntity(Integration integration)
{
return new IntegrationEntity
{
Id = integration.Id,
Name = integration.Name,
Description = integration.Description,
Type = integration.Type,
Provider = integration.Provider,
Status = integration.Status,
Endpoint = integration.Endpoint,
AuthRefUri = integration.AuthRefUri,
OrganizationId = integration.OrganizationId,
ConfigJson = integration.ConfigJson,
LastHealthStatus = integration.LastHealthStatus,
LastHealthCheckAt = integration.LastHealthCheckAt,
CreatedAt = integration.CreatedAt,
UpdatedAt = integration.UpdatedAt,
CreatedBy = integration.CreatedBy,
UpdatedBy = integration.UpdatedBy,
TenantId = integration.TenantId,
TagsJson = integration.Tags.Count > 0 ? JsonSerializer.Serialize(integration.Tags) : null,
IsDeleted = integration.IsDeleted
};
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Integrations.Persistence</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Integrations.Core\StellaOps.Integrations.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,192 @@
using System.Net.Http.Headers;
using System.Text.Json;
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
namespace StellaOps.Integrations.Plugin.GitHubApp;
/// <summary>
/// GitHub App connector plugin for SCM integration.
/// Supports GitHub.com and GitHub Enterprise Server.
/// </summary>
public sealed class GitHubAppConnectorPlugin : IIntegrationConnectorPlugin
{
public string Name => "github-app";
public IntegrationType Type => IntegrationType.Scm;
public IntegrationProvider Provider => IntegrationProvider.GitHubApp;
public bool IsAvailable(IServiceProvider services) => true;
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = DateTimeOffset.UtcNow;
using var client = CreateHttpClient(config);
try
{
// Call GitHub API to verify authentication
var response = await client.GetAsync("/app", cancellationToken);
var duration = DateTimeOffset.UtcNow - startTime;
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var app = JsonSerializer.Deserialize<GitHubAppResponse>(content, JsonOptions);
return new TestConnectionResult(
Success: true,
Message: $"Connected as GitHub App: {app?.Name}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["appName"] = app?.Name ?? "unknown",
["appId"] = app?.Id.ToString() ?? "unknown",
["slug"] = app?.Slug ?? "unknown"
},
Duration: duration);
}
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
return new TestConnectionResult(
Success: false,
Message: $"GitHub returned {response.StatusCode}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["statusCode"] = ((int)response.StatusCode).ToString(),
["error"] = errorContent.Length > 200 ? errorContent[..200] : errorContent
},
Duration: duration);
}
catch (Exception ex)
{
var duration = DateTimeOffset.UtcNow - startTime;
return new TestConnectionResult(
Success: false,
Message: $"Connection failed: {ex.Message}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["error"] = ex.GetType().Name
},
Duration: duration);
}
}
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = DateTimeOffset.UtcNow;
using var client = CreateHttpClient(config);
try
{
// Check GitHub API status
var response = await client.GetAsync("/rate_limit", cancellationToken);
var duration = DateTimeOffset.UtcNow - startTime;
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var rateLimit = JsonSerializer.Deserialize<GitHubRateLimitResponse>(content, JsonOptions);
var remaining = rateLimit?.Resources?.Core?.Remaining ?? 0;
var limit = rateLimit?.Resources?.Core?.Limit ?? 1;
var percentUsed = (int)((1 - (double)remaining / limit) * 100);
var status = percentUsed switch
{
< 80 => HealthStatus.Healthy,
< 95 => HealthStatus.Degraded,
_ => HealthStatus.Unhealthy
};
return new HealthCheckResult(
Status: status,
Message: $"Rate limit: {remaining}/{limit} remaining ({percentUsed}% used)",
Details: new Dictionary<string, string>
{
["remaining"] = remaining.ToString(),
["limit"] = limit.ToString(),
["percentUsed"] = percentUsed.ToString()
},
CheckedAt: DateTimeOffset.UtcNow,
Duration: duration);
}
return new HealthCheckResult(
Status: HealthStatus.Unhealthy,
Message: $"GitHub returned {response.StatusCode}",
Details: new Dictionary<string, string> { ["statusCode"] = ((int)response.StatusCode).ToString() },
CheckedAt: DateTimeOffset.UtcNow,
Duration: duration);
}
catch (Exception ex)
{
var duration = DateTimeOffset.UtcNow - startTime;
return new HealthCheckResult(
Status: HealthStatus.Unhealthy,
Message: $"Health check failed: {ex.Message}",
Details: new Dictionary<string, string> { ["error"] = ex.GetType().Name },
CheckedAt: DateTimeOffset.UtcNow,
Duration: duration);
}
}
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 client = new HttpClient
{
BaseAddress = new Uri(baseUrl),
Timeout = TimeSpan.FromSeconds(30)
};
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOps", "1.0"));
// Add JWT token if provided (GitHub App authentication)
if (!string.IsNullOrEmpty(config.ResolvedSecret))
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", config.ResolvedSecret);
}
return client;
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private sealed class GitHubAppResponse
{
public long Id { get; set; }
public string? Name { get; set; }
public string? Slug { get; set; }
}
private sealed class GitHubRateLimitResponse
{
public GitHubResources? Resources { get; set; }
}
private sealed class GitHubResources
{
public GitHubRateLimit? Core { get; set; }
}
private sealed class GitHubRateLimit
{
public int Limit { get; set; }
public int Remaining { get; set; }
public int Reset { get; set; }
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Integrations.Plugin.GitHubApp</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,166 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
namespace StellaOps.Integrations.Plugin.Harbor;
/// <summary>
/// Harbor container registry connector plugin.
/// Supports Harbor v2.x API.
/// </summary>
public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin
{
public string Name => "harbor";
public IntegrationType Type => IntegrationType.Registry;
public IntegrationProvider Provider => IntegrationProvider.Harbor;
public bool IsAvailable(IServiceProvider services) => true;
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = DateTimeOffset.UtcNow;
using var client = CreateHttpClient(config);
try
{
// Call Harbor health endpoint
var response = await client.GetAsync("/api/v2.0/health", cancellationToken);
var duration = DateTimeOffset.UtcNow - startTime;
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var health = JsonSerializer.Deserialize<HarborHealthResponse>(content, JsonOptions);
return new TestConnectionResult(
Success: health?.Status == "healthy",
Message: health?.Status == "healthy" ? "Harbor connection successful" : $"Harbor unhealthy: {health?.Status}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["status"] = health?.Status ?? "unknown",
["version"] = response.Headers.TryGetValues("X-Harbor-Version", out var versions)
? versions.FirstOrDefault() ?? "unknown"
: "unknown"
},
Duration: duration);
}
return new TestConnectionResult(
Success: false,
Message: $"Harbor returned {response.StatusCode}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["statusCode"] = ((int)response.StatusCode).ToString()
},
Duration: duration);
}
catch (Exception ex)
{
var duration = DateTimeOffset.UtcNow - startTime;
return new TestConnectionResult(
Success: false,
Message: $"Connection failed: {ex.Message}",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["error"] = ex.GetType().Name
},
Duration: duration);
}
}
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = DateTimeOffset.UtcNow;
using var client = CreateHttpClient(config);
try
{
var response = await client.GetAsync("/api/v2.0/health", cancellationToken);
var duration = DateTimeOffset.UtcNow - startTime;
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
var health = JsonSerializer.Deserialize<HarborHealthResponse>(content, JsonOptions);
var status = health?.Status switch
{
"healthy" => HealthStatus.Healthy,
"degraded" => HealthStatus.Degraded,
_ => HealthStatus.Unhealthy
};
return new HealthCheckResult(
Status: status,
Message: $"Harbor status: {health?.Status}",
Details: health?.Components?.ToDictionary(c => c.Name, c => c.Status) ?? new Dictionary<string, string>(),
CheckedAt: DateTimeOffset.UtcNow,
Duration: duration);
}
return new HealthCheckResult(
Status: HealthStatus.Unhealthy,
Message: $"Harbor returned {response.StatusCode}",
Details: new Dictionary<string, string> { ["statusCode"] = ((int)response.StatusCode).ToString() },
CheckedAt: DateTimeOffset.UtcNow,
Duration: duration);
}
catch (Exception ex)
{
var duration = DateTimeOffset.UtcNow - startTime;
return new HealthCheckResult(
Status: HealthStatus.Unhealthy,
Message: $"Health check failed: {ex.Message}",
Details: new Dictionary<string, string> { ["error"] = ex.GetType().Name },
CheckedAt: DateTimeOffset.UtcNow,
Duration: duration);
}
}
private static HttpClient CreateHttpClient(IntegrationConfig config)
{
var client = new HttpClient
{
BaseAddress = new Uri(config.Endpoint.TrimEnd('/')),
Timeout = TimeSpan.FromSeconds(30)
};
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// Add basic auth if secret is provided
if (!string.IsNullOrEmpty(config.ResolvedSecret))
{
// Expect format: username:password
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(config.ResolvedSecret));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
}
return client;
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private sealed class HarborHealthResponse
{
public string? Status { get; set; }
public List<HarborHealthComponent>? Components { get; set; }
}
private sealed class HarborHealthComponent
{
public string Name { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Integrations.Plugin.Harbor</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,61 @@
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
namespace StellaOps.Integrations.Plugin.InMemory;
/// <summary>
/// In-memory connector plugin for testing and development.
/// Always succeeds with simulated delays.
/// </summary>
public sealed class InMemoryConnectorPlugin : IIntegrationConnectorPlugin
{
public string Name => "inmemory";
public IntegrationType Type => IntegrationType.Registry;
public IntegrationProvider Provider => IntegrationProvider.InMemory;
public bool IsAvailable(IServiceProvider services) => true;
public async Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = DateTimeOffset.UtcNow;
// Simulate network delay
await Task.Delay(100, cancellationToken);
var duration = DateTimeOffset.UtcNow - startTime;
return new TestConnectionResult(
Success: true,
Message: "In-memory connector test successful",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["provider"] = config.Provider.ToString(),
["simulated"] = "true"
},
Duration: duration);
}
public async Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken cancellationToken = default)
{
var startTime = DateTimeOffset.UtcNow;
// Simulate health check
await Task.Delay(50, cancellationToken);
var duration = DateTimeOffset.UtcNow - startTime;
return new HealthCheckResult(
Status: HealthStatus.Healthy,
Message: "In-memory connector is healthy",
Details: new Dictionary<string, string>
{
["endpoint"] = config.Endpoint,
["uptime"] = "simulated"
},
CheckedAt: DateTimeOffset.UtcNow,
Duration: duration);
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.Integrations.Plugin.InMemory</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Integrations.Contracts\StellaOps.Integrations.Contracts.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,80 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Integrations.Core;
using StellaOps.Integrations.WebService;
using Xunit;
namespace StellaOps.Integrations.Tests;
public class IntegrationPluginLoaderTests
{
[Trait("Category", "Unit")]
[Fact]
public void Plugins_ReturnsEmptyInitially()
{
// Arrange
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
// Act
var plugins = loader.Plugins;
// Assert
plugins.Should().BeEmpty();
}
[Trait("Category", "Unit")]
[Fact]
public void GetByProvider_WithNoPlugins_ReturnsNull()
{
// Arrange
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
// Act
var result = loader.GetByProvider(IntegrationProvider.Harbor);
// Assert
result.Should().BeNull();
}
[Trait("Category", "Unit")]
[Fact]
public void GetByType_WithNoPlugins_ReturnsEmpty()
{
// Arrange
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
// Act
var result = loader.GetByType(IntegrationType.Registry);
// Assert
result.Should().BeEmpty();
}
[Trait("Category", "Unit")]
[Fact]
public void LoadFromDirectory_WithNonExistentDirectory_ReturnsEmpty()
{
// Arrange
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
// Act
var result = loader.LoadFromDirectory("/non/existent/path");
// Assert
result.Should().BeEmpty();
}
[Trait("Category", "Unit")]
[Fact]
public void LoadFromAssemblies_WithEmptyAssemblies_ReturnsEmpty()
{
// Arrange
var loader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
// Act
var result = loader.LoadFromAssemblies([]);
// Assert
result.Should().BeEmpty();
}
}

View File

@@ -0,0 +1,343 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Integrations.Contracts;
using StellaOps.Integrations.Core;
using StellaOps.Integrations.Persistence;
using StellaOps.Integrations.WebService;
using Xunit;
namespace StellaOps.Integrations.Tests;
public class IntegrationServiceTests
{
private readonly Mock<IIntegrationRepository> _repositoryMock;
private readonly Mock<IIntegrationEventPublisher> _eventPublisherMock;
private readonly Mock<IIntegrationAuditLogger> _auditLoggerMock;
private readonly Mock<IAuthRefResolver> _authRefResolverMock;
private readonly IntegrationPluginLoader _pluginLoader;
private readonly IntegrationService _service;
public IntegrationServiceTests()
{
_repositoryMock = new Mock<IIntegrationRepository>();
_eventPublisherMock = new Mock<IIntegrationEventPublisher>();
_auditLoggerMock = new Mock<IIntegrationAuditLogger>();
_authRefResolverMock = new Mock<IAuthRefResolver>();
_pluginLoader = new IntegrationPluginLoader(NullLogger<IntegrationPluginLoader>.Instance);
_service = new IntegrationService(
_repositoryMock.Object,
_pluginLoader,
_eventPublisherMock.Object,
_auditLoggerMock.Object,
_authRefResolverMock.Object,
NullLogger<IntegrationService>.Instance);
}
[Trait("Category", "Unit")]
[Fact]
public async Task CreateAsync_WithValidRequest_CreatesIntegration()
{
// Arrange
var request = new CreateIntegrationRequest(
Name: "Test Registry",
Description: "Test description",
Type: IntegrationType.Registry,
Provider: IntegrationProvider.Harbor,
Endpoint: "https://harbor.example.com",
AuthRefUri: "authref://vault/harbor#credentials",
OrganizationId: "myorg",
ExtendedConfig: null,
Tags: ["test", "dev"]);
_repositoryMock
.Setup(r => r.CreateAsync(It.IsAny<Integration>(), It.IsAny<CancellationToken>()))
.Returns<Integration, CancellationToken>((i, _) => Task.FromResult(i));
// Act
var result = await _service.CreateAsync(request, "test-user", "tenant-1");
// 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");
_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);
}
[Trait("Category", "Unit")]
[Fact]
public async Task GetByIdAsync_WithExistingId_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);
// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(integration.Id);
result.Name.Should().Be(integration.Name);
}
[Trait("Category", "Unit")]
[Fact]
public async Task GetByIdAsync_WithNonExistingId_ReturnsNull()
{
// Arrange
var id = Guid.NewGuid();
_repositoryMock
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync((Integration?)null);
// Act
var result = await _service.GetByIdAsync(id);
// Assert
result.Should().BeNull();
}
[Trait("Category", "Unit")]
[Fact]
public async Task ListAsync_WithFilters_ReturnsFilteredResults()
{
// Arrange
var integrations = new[]
{
CreateTestIntegration(type: IntegrationType.Registry),
CreateTestIntegration(type: IntegrationType.Registry),
CreateTestIntegration(type: IntegrationType.Scm)
};
_repositoryMock
.Setup(r => r.GetAllAsync(
It.Is<IntegrationQuery>(q => q.Type == IntegrationType.Registry),
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.IsAny<CancellationToken>()))
.ReturnsAsync(2);
var query = new ListIntegrationsQuery(Type: IntegrationType.Registry);
// 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.TotalCount.Should().Be(2);
}
[Trait("Category", "Unit")]
[Fact]
public async Task UpdateAsync_WithExistingIntegration_UpdatesAndPublishesEvent()
{
// Arrange
var integration = CreateTestIntegration();
var request = new UpdateIntegrationRequest(
Name: "Updated Name",
Description: "Updated description",
Endpoint: "https://updated.example.com",
AuthRefUri: null,
OrganizationId: null,
ExtendedConfig: null,
Tags: ["updated"],
Status: null);
_repositoryMock
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(integration);
_repositoryMock
.Setup(r => r.UpdateAsync(It.IsAny<Integration>(), It.IsAny<CancellationToken>()))
.Returns<Integration, CancellationToken>((i, _) => Task.FromResult(i));
// Act
var result = await _service.UpdateAsync(integration.Id, request, "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);
}
[Trait("Category", "Unit")]
[Fact]
public async Task UpdateAsync_WithNonExistingIntegration_ReturnsNull()
{
// Arrange
var id = Guid.NewGuid();
_repositoryMock
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync((Integration?)null);
var request = new UpdateIntegrationRequest(
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");
// Assert
result.Should().BeNull();
}
[Trait("Category", "Unit")]
[Fact]
public async Task DeleteAsync_WithExistingIntegration_DeletesAndPublishesEvent()
{
// Arrange
var integration = CreateTestIntegration();
_repositoryMock
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(integration);
_repositoryMock
.Setup(r => r.DeleteAsync(integration.Id, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
// Act
var result = await _service.DeleteAsync(integration.Id, "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()
{
// Arrange
var id = Guid.NewGuid();
_repositoryMock
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync((Integration?)null);
// Act
var result = await _service.DeleteAsync(id, "test-user");
// Assert
result.Should().BeFalse();
}
[Trait("Category", "Unit")]
[Fact]
public async Task TestConnectionAsync_WithNoPlugin_ReturnsFailureResult()
{
// Arrange
var integration = CreateTestIntegration(provider: IntegrationProvider.Harbor);
_repositoryMock
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(integration);
// No plugins loaded in _pluginLoader
// 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");
}
[Trait("Category", "Unit")]
[Fact]
public async Task TestConnectionAsync_WithNonExistingIntegration_ReturnsNull()
{
// Arrange
var id = Guid.NewGuid();
_repositoryMock
.Setup(r => r.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync((Integration?)null);
// Act
var result = await _service.TestConnectionAsync(id, "test-user");
// Assert
result.Should().BeNull();
}
[Trait("Category", "Unit")]
[Fact]
public async Task CheckHealthAsync_WithNoPlugin_ReturnsUnknownStatus()
{
// Arrange
var integration = CreateTestIntegration();
_repositoryMock
.Setup(r => r.GetByIdAsync(integration.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(integration);
// No plugins loaded
// 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()
{
// Act
var result = _service.GetSupportedProviders();
// Assert
result.Should().BeEmpty();
}
private static Integration CreateTestIntegration(
IntegrationType type = IntegrationType.Registry,
IntegrationProvider provider = IntegrationProvider.Harbor)
{
return new Integration
{
Id = Guid.NewGuid(),
Name = "Test Integration",
Type = type,
Provider = provider,
Status = IntegrationStatus.Active,
Endpoint = "https://example.com",
Description = "Test description",
Tags = ["test"],
CreatedBy = "test-user"
};
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Integrations.Contracts/StellaOps.Integrations.Contracts.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Integrations.Core/StellaOps.Integrations.Core.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>