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;