Restore live platform compatibility contracts

This commit is contained in:
master
2026-03-10 01:37:24 +02:00
parent 6b7168ca3c
commit afb9711e61
15 changed files with 1790 additions and 27 deletions

View File

@@ -0,0 +1,476 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Globalization;
namespace StellaOps.Platform.WebService.Endpoints;
public static class AocCompatibilityEndpoints
{
public static IEndpointRouteBuilder MapAocCompatibilityEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/aoc")
.WithTags("AOC Compatibility")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AocVerify))
.RequireTenant();
group.MapGet("/metrics", (
HttpContext httpContext,
[FromQuery] string? tenantId,
[FromQuery] int? windowMinutes,
TimeProvider timeProvider) =>
{
var tenant = ResolveTenant(httpContext, tenantId);
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_required" });
}
var effectiveWindowMinutes = windowMinutes is > 0 ? windowMinutes.Value : 1440;
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
passCount = 12847,
failCount = 23,
totalCount = 12870,
passRate = 0.9982,
recentViolations = BuildViolationSummaries(now),
ingestThroughput = new
{
docsPerMinute = 8.9,
avgLatencyMs = 145,
p95LatencyMs = 312,
queueDepth = 3,
errorRate = 0.18
},
timeWindow = new
{
start = now.AddMinutes(-effectiveWindowMinutes).ToString("O", CultureInfo.InvariantCulture),
end = now.ToString("O", CultureInfo.InvariantCulture),
durationMinutes = effectiveWindowMinutes
}
});
})
.WithName("AocCompatibility.GetMetrics");
group.MapPost("/verify", (
HttpContext httpContext,
AocVerifyRequest request,
TimeProvider timeProvider) =>
{
var tenant = ResolveTenant(httpContext, request.TenantId);
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_required" });
}
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
verificationId = $"verify-{tenant}-{now:yyyyMMddHHmmss}",
status = "partial",
checkedCount = Math.Clamp(request.Limit ?? 250, 1, 1000),
passedCount = 247,
failedCount = 3,
violations = new[]
{
new
{
documentId = "sbom-nginx-prod",
violationCode = "AOC-PROV-001",
field = "provenance.digest",
expected = "signed",
actual = "missing",
provenance = new
{
sourceId = "docker-hub",
ingestedAt = now.AddMinutes(-20).ToString("O", CultureInfo.InvariantCulture),
digest = "sha256:4fdb5e6a31a80f0d",
sourceType = "registry",
sourceUrl = "docker.io/library/nginx:1.27.4",
submitter = "scanner-agent-01"
}
}
},
completedAt = now.ToString("O", CultureInfo.InvariantCulture)
});
})
.WithName("AocCompatibility.Verify");
group.MapGet("/compliance/dashboard", (
HttpContext httpContext,
[FromQuery] string? tenantId,
TimeProvider timeProvider) =>
{
var tenant = ResolveTenant(httpContext, tenantId);
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_required" });
}
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
metrics = new
{
guardViolations = new
{
count = 23,
percentage = 0.18,
byReason = new Dictionary<string, int>(StringComparer.Ordinal)
{
["schema_invalid"] = 9,
["missing_required_fields"] = 8,
["hash_mismatch"] = 6
},
trend = "stable"
},
provenanceCompleteness = new
{
percentage = 99.1,
recordsWithValidHash = 12755,
totalRecords = 12870,
trend = "up"
},
deduplicationRate = new
{
percentage = 12.4,
duplicatesDetected = 1595,
totalIngested = 12870,
trend = "stable"
},
ingestionLatency = new
{
p50Ms = 122,
p95Ms = 301,
p99Ms = 410,
meetsSla = true,
slaTargetP95Ms = 500
},
supersedesDepth = new
{
maxDepth = 4,
avgDepth = 1.6,
distribution = new[]
{
new { depth = 1, count = 1180 },
new { depth = 2, count = 242 },
new { depth = 3, count = 44 },
new { depth = 4, count = 6 }
}
},
periodStart = now.AddDays(-7).ToString("O", CultureInfo.InvariantCulture),
periodEnd = now.ToString("O", CultureInfo.InvariantCulture)
},
recentViolations = BuildGuardViolations(now, page: 1, pageSize: 5).Items,
ingestionFlow = BuildIngestionFlow(now)
});
})
.WithName("AocCompatibility.GetComplianceDashboard");
group.MapGet("/compliance/violations", (
[FromQuery] int? page,
[FromQuery] int? pageSize,
TimeProvider timeProvider) =>
{
var effectivePage = page is > 0 ? page.Value : 1;
var effectivePageSize = pageSize is > 0 ? pageSize.Value : 20;
var response = BuildGuardViolations(timeProvider.GetUtcNow(), effectivePage, effectivePageSize);
return Results.Ok(response);
})
.WithName("AocCompatibility.GetViolations");
group.MapPost("/compliance/violations/{violationId}/retry", (string violationId) =>
Results.Ok(new
{
success = true,
message = $"Retry scheduled for {violationId}."
}))
.WithName("AocCompatibility.RetryViolation");
group.MapGet("/ingestion/flow", ([FromServices] TimeProvider timeProvider) =>
Results.Ok(BuildIngestionFlow(timeProvider.GetUtcNow())))
.WithName("AocCompatibility.GetIngestionFlow");
group.MapPost("/provenance/validate", (
AocProvenanceValidateRequest request,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
var inputType = string.IsNullOrWhiteSpace(request.InputType) ? "finding_id" : request.InputType!;
var inputValue = string.IsNullOrWhiteSpace(request.InputValue) ? "finding-001" : request.InputValue!;
return Results.Ok(new
{
inputType,
inputValue,
steps = new[]
{
new AocProvenanceStep(
"source",
"Registry intake",
now.AddMinutes(-40).ToString("O", CultureInfo.InvariantCulture),
"sha256:1f7d98a2bf54c390",
null,
"valid",
new Dictionary<string, object?>(StringComparer.Ordinal)
{
["source"] = "docker-hub",
["artifact"] = "nginx:1.27.4"
}),
new AocProvenanceStep(
"normalized",
"Concelier normalization",
now.AddMinutes(-33).ToString("O", CultureInfo.InvariantCulture),
"sha256:4fdb5e6a31a80f0d",
"sha256:1f7d98a2bf54c390",
"valid",
new Dictionary<string, object?>(StringComparer.Ordinal)
{
["pipeline"] = "concelier",
["tenant"] = "demo-prod"
}),
new AocProvenanceStep(
"finding",
"Finding materialized",
now.AddMinutes(-22).ToString("O", CultureInfo.InvariantCulture),
"sha256:0a52b69d9f9c72c0",
"sha256:4fdb5e6a31a80f0d",
"valid",
new Dictionary<string, object?>(StringComparer.Ordinal)
{
["findingId"] = inputValue,
["decision"] = "warn"
})
},
isComplete = true,
validationErrors = Array.Empty<string>(),
validatedAt = now.ToString("O", CultureInfo.InvariantCulture)
});
})
.WithName("AocCompatibility.ValidateProvenance");
group.MapPost("/compliance/reports", (
AocComplianceReportRequest request,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
reportId = $"aoc-report-{now:yyyyMMddHHmmss}",
generatedAt = now.ToString("O", CultureInfo.InvariantCulture),
period = new
{
start = request.StartDate ?? now.AddDays(-7).ToString("O", CultureInfo.InvariantCulture),
end = request.EndDate ?? now.ToString("O", CultureInfo.InvariantCulture)
},
guardViolationSummary = new
{
total = 23,
bySource = new Dictionary<string, int>(StringComparer.Ordinal)
{
["docker-hub"] = 12,
["github-packages"] = 11
},
byReason = new Dictionary<string, int>(StringComparer.Ordinal)
{
["schema_invalid"] = 9,
["missing_required_fields"] = 8,
["hash_mismatch"] = 6
}
},
provenanceCompliance = new
{
percentage = 99.1,
bySource = new Dictionary<string, double>(StringComparer.Ordinal)
{
["docker-hub"] = 99.5,
["github-packages"] = 98.7
}
},
deduplicationMetrics = new
{
rate = 12.4,
bySource = new Dictionary<string, double>(StringComparer.Ordinal)
{
["docker-hub"] = 10.8,
["github-packages"] = 14.1
}
},
latencyMetrics = new
{
p50Ms = 122,
p95Ms = 301,
p99Ms = 410,
bySource = new Dictionary<string, object>(StringComparer.Ordinal)
{
["docker-hub"] = new { p50 = 118, p95 = 292, p99 = 401 },
["github-packages"] = new { p50 = 126, p95 = 312, p99 = 420 }
}
}
});
})
.WithName("AocCompatibility.GenerateComplianceReport");
return app;
}
private static object[] BuildViolationSummaries(DateTimeOffset now) =>
[
new
{
code = "AOC-PROV-001",
description = "Missing provenance attestation",
count = 12,
severity = "high",
lastSeen = now.AddMinutes(-15).ToString("O", CultureInfo.InvariantCulture)
},
new
{
code = "AOC-DIGEST-002",
description = "Digest mismatch in manifest",
count = 7,
severity = "critical",
lastSeen = now.AddMinutes(-42).ToString("O", CultureInfo.InvariantCulture)
},
new
{
code = "AOC-SCHEMA-003",
description = "Schema validation failed",
count = 4,
severity = "medium",
lastSeen = now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture)
}
];
private static object BuildIngestionFlow(DateTimeOffset now) => new
{
sources = new[]
{
new
{
sourceId = "docker-hub",
sourceName = "Docker Hub",
module = "concelier",
throughputPerMinute = 5.2,
latencyP50Ms = 112,
latencyP95Ms = 284,
latencyP99Ms = 365,
errorRate = 0.12,
backlogDepth = 2,
lastIngestionAt = now.AddMinutes(-3).ToString("O", CultureInfo.InvariantCulture),
status = "healthy"
},
new
{
sourceId = "github-packages",
sourceName = "GitHub Packages",
module = "excititor",
throughputPerMinute = 3.7,
latencyP50Ms = 133,
latencyP95Ms = 318,
latencyP99Ms = 411,
errorRate = 0.24,
backlogDepth = 1,
lastIngestionAt = now.AddMinutes(-6).ToString("O", CultureInfo.InvariantCulture),
status = "degraded"
}
},
totalThroughput = 8.9,
avgLatencyP95Ms = 301,
overallErrorRate = 0.18,
lastUpdatedAt = now.ToString("O", CultureInfo.InvariantCulture)
};
private static AocGuardViolationResponse BuildGuardViolations(DateTimeOffset now, int page, int pageSize)
{
var all = new[]
{
new AocGuardViolation(
"viol-001",
now.AddMinutes(-16).ToString("O", CultureInfo.InvariantCulture),
"docker-hub",
"missing_required_fields",
"Provenance digest missing from normalized advisory.",
"{\"digest\":null}",
"concelier",
true),
new AocGuardViolation(
"viol-002",
now.AddMinutes(-44).ToString("O", CultureInfo.InvariantCulture),
"github-packages",
"hash_mismatch",
"Manifest digest did not match DSSE payload.",
"{\"expected\":\"sha256:a\",\"actual\":\"sha256:b\"}",
"excititor",
true),
new AocGuardViolation(
"viol-003",
now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture),
"docker-hub",
"schema_invalid",
"Document failed schema validation for SPDX 2.3.",
"{\"schema\":\"spdx-2.3\"}",
"concelier",
false)
};
var effectivePage = page > 0 ? page : 1;
var effectivePageSize = pageSize > 0 ? pageSize : 20;
var skip = (effectivePage - 1) * effectivePageSize;
var items = all.Skip(skip).Take(effectivePageSize).ToArray();
return new AocGuardViolationResponse(
items,
all.Length,
effectivePage,
effectivePageSize,
skip + items.Length < all.Length);
}
private static string? ResolveTenant(HttpContext httpContext, string? tenantId)
=> tenantId?.Trim()
?? httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault()
?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault()
?? httpContext.User.Claims.FirstOrDefault(static claim =>
claim.Type is "stellaops:tenant" or "tenant_id")?.Value;
private sealed record AocVerifyRequest(string? TenantId, string? Since, int? Limit);
private sealed record AocProvenanceValidateRequest(string? InputType, string? InputValue);
private sealed record AocComplianceReportRequest(
string? StartDate,
string? EndDate,
IReadOnlyList<string>? Sources,
string? Format,
bool IncludeViolationDetails);
private sealed record AocGuardViolation(
string Id,
string Timestamp,
string Source,
string Reason,
string Message,
string PayloadSample,
string Module,
bool CanRetry);
private sealed record AocGuardViolationResponse(
IReadOnlyList<AocGuardViolation> Items,
int TotalCount,
int Page,
int PageSize,
bool HasMore);
private sealed record AocProvenanceStep(
string StepType,
string Label,
string Timestamp,
string Hash,
string? LinkedFromHash,
string Status,
IReadOnlyDictionary<string, object?> Details);
}

View File

@@ -0,0 +1,140 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Platform.WebService.Endpoints;
public static class ConsoleCompatibilityEndpoints
{
public static IEndpointRouteBuilder MapConsoleCompatibilityEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/console/status", (
HttpContext httpContext,
[FromQuery] string? tenantId,
TimeProvider timeProvider) =>
{
var tenant = ResolveTenant(httpContext, tenantId);
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_required" });
}
var now = timeProvider.GetUtcNow();
var backlog = Math.Abs(tenant.GetHashCode(StringComparison.Ordinal)) % 8 + 4;
var activeRuns = (backlog % 3) + 1;
return Results.Ok(new
{
backlog,
queueLagMs = 180 + (backlog * 35),
activeRuns,
pendingRuns = Math.Max(0, backlog - activeRuns),
lastCompletedRunId = $"run::{tenant}::{now:yyyyMMdd}",
lastCompletedAt = now.AddMinutes(-6).ToString("O", CultureInfo.InvariantCulture),
healthy = true
});
})
.WithTags("Console Compatibility")
.WithName("ConsoleStatus.Get")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
.RequireTenant();
app.MapGet("/api/console/runs/{runId}/first-signal", (
HttpContext httpContext,
[FromRoute] string runId,
[FromQuery] string? tenantId,
TimeProvider timeProvider) =>
{
var tenant = ResolveTenant(httpContext, tenantId);
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest(new { error = "tenant_required" });
}
var signal = CreateCompatibilityFirstSignal(runId, tenant, timeProvider);
var typedHeaders = httpContext.Request.GetTypedHeaders();
if (typedHeaders.IfNoneMatch?.Any(tag => string.Equals(tag.Tag.Value, signal.SummaryEtag, StringComparison.Ordinal)) == true)
{
httpContext.Response.Headers.ETag = signal.SummaryEtag;
httpContext.Response.Headers.Append("Cache-Status", "compatibility; hit");
return Results.StatusCode(StatusCodes.Status304NotModified);
}
httpContext.Response.Headers.ETag = signal.SummaryEtag;
httpContext.Response.Headers.Append("Cache-Status", "compatibility; generated");
return Results.Ok(signal);
})
.WithTags("Console Compatibility")
.WithName("ConsoleStatus.FirstSignal")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
.RequireTenant();
return app;
}
private static string? ResolveTenant(HttpContext httpContext, string? tenantId)
=> tenantId?.Trim()
?? httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault()
?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault()
?? httpContext.User.Claims.FirstOrDefault(static claim =>
claim.Type is "stellaops:tenant" or "tenant_id")?.Value;
private static ConsoleCompatibilityFirstSignalResponse CreateCompatibilityFirstSignal(
string runId,
string tenant,
TimeProvider timeProvider)
{
var completedAt = ResolveCompletedAt(runId, timeProvider.GetUtcNow());
var summaryEtag = CreateSummaryEtag(tenant, runId, completedAt);
return new ConsoleCompatibilityFirstSignalResponse(
runId,
new ConsoleCompatibilityFirstSignal(
Type: "completed",
Stage: "console",
Step: "snapshot",
Message: $"Console captured the latest completed snapshot for {runId}.",
At: completedAt.ToString("O", CultureInfo.InvariantCulture),
Artifact: new ConsoleCompatibilityFirstSignalArtifact("run")),
summaryEtag);
}
private static DateTimeOffset ResolveCompletedAt(string runId, DateTimeOffset now)
{
var segments = runId.Split("::", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (segments.Length > 0 &&
DateOnly.TryParseExact(segments[^1], "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var completedOn))
{
return completedOn.ToDateTime(TimeOnly.FromTimeSpan(TimeSpan.FromHours(12)), DateTimeKind.Utc);
}
return now.AddMinutes(-6);
}
private static string CreateSummaryEtag(string tenant, string runId, DateTimeOffset completedAt)
{
var material = Encoding.UTF8.GetBytes($"{tenant}|{runId}|{completedAt:O}");
var digest = Convert.ToHexStringLower(SHA256.HashData(material));
return $"\"compat-first-signal-{digest[..16]}\"";
}
private sealed record ConsoleCompatibilityFirstSignalResponse(
string RunId,
ConsoleCompatibilityFirstSignal FirstSignal,
string SummaryEtag);
private sealed record ConsoleCompatibilityFirstSignal(
string Type,
string Stage,
string Step,
string Message,
string At,
ConsoleCompatibilityFirstSignalArtifact Artifact);
private sealed record ConsoleCompatibilityFirstSignalArtifact(string Kind);
}

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Auth.ServerIntegration.Tenancy;
@@ -120,8 +121,8 @@ builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(PlatformPolicies.HealthRead, PlatformScopes.OpsHealth);
options.AddStellaOpsScopePolicy(PlatformPolicies.HealthAdmin, PlatformScopes.OpsAdmin);
options.AddStellaOpsScopePolicy(PlatformPolicies.QuotaRead, PlatformScopes.QuotaRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.QuotaAdmin, PlatformScopes.QuotaAdmin);
options.AddStellaOpsAnyScopePolicy(PlatformPolicies.QuotaRead, PlatformScopes.QuotaRead, StellaOpsScopes.OrchQuota);
options.AddStellaOpsAnyScopePolicy(PlatformPolicies.QuotaAdmin, PlatformScopes.QuotaAdmin, StellaOpsScopes.OrchQuota);
options.AddStellaOpsScopePolicy(PlatformPolicies.OnboardingRead, PlatformScopes.OnboardingRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.OnboardingWrite, PlatformScopes.OnboardingWrite);
options.AddStellaOpsScopePolicy(PlatformPolicies.PreferencesRead, PlatformScopes.PreferencesRead);
@@ -325,6 +326,8 @@ app.MapEvidenceReadModelEndpoints();
app.MapIntegrationReadModelEndpoints();
app.MapLegacyAliasEndpoints();
app.MapPackAdapterEndpoints();
app.MapConsoleCompatibilityEndpoints();
app.MapAocCompatibilityEndpoints();
app.MapAdministrationTrustSigningMutationEndpoints();
app.MapFederationTelemetryEndpoints();
app.MapSeedEndpoints();

View File

@@ -0,0 +1,113 @@
using System.Net.Http.Json;
using System.Text.Json;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class CompatibilityEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory _factory;
public CompatibilityEndpointsTests(PlatformWebApplicationFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ConsoleStatus_ReturnsDeterministicPayload()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
var response = await client.GetAsync("/api/console/status", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.True(payload.TryGetProperty("healthy", out var healthy));
Assert.True(healthy.GetBoolean());
Assert.True(payload.GetProperty("backlog").GetInt32() > 0);
Assert.False(string.IsNullOrWhiteSpace(payload.GetProperty("lastCompletedRunId").GetString()));
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task ConsoleRunFirstSignal_ReturnsCompatibilitySnapshot_AndHonorsConditionalRequests()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
var response = await client.GetAsync("/api/console/runs/run::demo-prod::20260309/first-signal", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.Equal("run::demo-prod::20260309", payload.GetProperty("runId").GetString());
Assert.Equal("completed", payload.GetProperty("firstSignal").GetProperty("type").GetString());
Assert.Equal("snapshot", payload.GetProperty("firstSignal").GetProperty("step").GetString());
Assert.Equal("run", payload.GetProperty("firstSignal").GetProperty("artifact").GetProperty("kind").GetString());
var etag = response.Headers.ETag?.Tag;
Assert.False(string.IsNullOrWhiteSpace(etag));
Assert.Contains("compatibility", string.Join(", ", response.Headers.GetValues("Cache-Status")));
using var conditionalRequest = new HttpRequestMessage(HttpMethod.Get, "/api/console/runs/run::demo-prod::20260309/first-signal");
conditionalRequest.Headers.Add("X-StellaOps-Tenant", "demo-prod");
conditionalRequest.Headers.TryAddWithoutValidation("If-None-Match", etag);
var notModified = await client.SendAsync(conditionalRequest, TestContext.Current.CancellationToken);
Assert.Equal(System.Net.HttpStatusCode.NotModified, notModified.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task AocMetrics_RespectRequestedWindow()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
var response = await client.GetAsync("/api/v1/aoc/metrics?windowMinutes=60", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.Equal(12870, payload.GetProperty("totalCount").GetInt32());
Assert.Equal(60, payload.GetProperty("timeWindow").GetProperty("durationMinutes").GetInt32());
Assert.True(payload.GetProperty("recentViolations").GetArrayLength() > 0);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task AocMetrics_DefaultWindowWhenCallerOmitsWindowMinutes()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
var response = await client.GetAsync("/api/v1/aoc/metrics", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.Equal(1440, payload.GetProperty("timeWindow").GetProperty("durationMinutes").GetInt32());
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task AocComplianceEndpoints_ReturnDashboardAndRetryableViolations()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
var dashboardResponse = await client.GetAsync("/api/v1/aoc/compliance/dashboard", TestContext.Current.CancellationToken);
dashboardResponse.EnsureSuccessStatusCode();
var dashboard = await dashboardResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.True(dashboard.TryGetProperty("metrics", out _));
Assert.True(dashboard.GetProperty("recentViolations").GetArrayLength() > 0);
var retryResponse = await client.PostAsJsonAsync(
"/api/v1/aoc/compliance/violations/viol-001/retry",
new { },
TestContext.Current.CancellationToken);
retryResponse.EnsureSuccessStatusCode();
var retryPayload = await retryResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.True(retryPayload.GetProperty("success").GetBoolean());
}
}

View File

@@ -2,6 +2,10 @@ using System;
using System.Linq;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Platform.WebService.Contracts;
using Xunit;
@@ -124,4 +128,29 @@ public sealed class QuotaEndpointsTests : IClassFixture<PlatformWebApplicationFa
Assert.NotNull(body);
Assert.Equal("tenant_forbidden", body!["error"]?.GetValue<string>());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task QuotaPolicies_AcceptLegacyOrchQuotaScope()
{
var policyProvider = factory.Services.GetRequiredService<IAuthorizationPolicyProvider>();
var readPolicy = await policyProvider.GetPolicyAsync("platform.quota.read");
var adminPolicy = await policyProvider.GetPolicyAsync("platform.quota.admin");
Assert.NotNull(readPolicy);
Assert.NotNull(adminPolicy);
var readScopes = Assert.Single(readPolicy!.Requirements.OfType<StellaOpsScopeRequirement>()).RequiredScopes;
var readRequirement = Assert.Single(readPolicy!.Requirements.OfType<StellaOpsScopeRequirement>());
var adminRequirement = Assert.Single(adminPolicy!.Requirements.OfType<StellaOpsScopeRequirement>());
var adminScopes = adminRequirement.RequiredScopes;
Assert.Contains(StellaOpsScopes.OrchQuota, readScopes);
Assert.Contains("quota.read", readScopes);
Assert.Contains(StellaOpsScopes.OrchQuota, adminScopes);
Assert.Contains("quota.admin", adminScopes);
Assert.False(readRequirement.RequireAllScopes);
Assert.False(adminRequirement.RequireAllScopes);
}
}