wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -93,6 +93,64 @@ public sealed class ContextEndpointsTests : IClassFixture<PlatformWebApplication
Assert.Equal(updated.TimeWindow, stored.TimeWindow);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Preferences_AreIsolatedPerTenantForSameActor()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "shared-actor");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-context-a");
var tenantARequest = new PlatformContextPreferencesRequest(
Regions: new[] { "us-east" },
Environments: new[] { "us-prod" },
TimeWindow: "7d");
var tenantAResponse = await client.PutAsJsonAsync(
"/api/v2/context/preferences",
tenantARequest,
TestContext.Current.CancellationToken);
tenantAResponse.EnsureSuccessStatusCode();
var tenantAPreferences = await tenantAResponse.Content.ReadFromJsonAsync<PlatformContextPreferences>(
TestContext.Current.CancellationToken);
Assert.NotNull(tenantAPreferences);
client.DefaultRequestHeaders.Remove("X-StellaOps-Tenant");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-context-b");
var tenantBDefaults = await client.GetFromJsonAsync<PlatformContextPreferences>(
"/api/v2/context/preferences",
TestContext.Current.CancellationToken);
Assert.NotNull(tenantBDefaults);
Assert.Equal("tenant-context-b", tenantBDefaults!.TenantId);
Assert.NotEqual("7d", tenantBDefaults.TimeWindow);
Assert.NotEqual(tenantAPreferences!.Regions.ToArray(), tenantBDefaults.Regions.ToArray());
var tenantBRequest = new PlatformContextPreferencesRequest(
Regions: new[] { "eu-west" },
Environments: new[] { "eu-prod" },
TimeWindow: "30d");
var tenantBUpdate = await client.PutAsJsonAsync(
"/api/v2/context/preferences",
tenantBRequest,
TestContext.Current.CancellationToken);
tenantBUpdate.EnsureSuccessStatusCode();
client.DefaultRequestHeaders.Remove("X-StellaOps-Tenant");
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-context-a");
var tenantAReloaded = await client.GetFromJsonAsync<PlatformContextPreferences>(
"/api/v2/context/preferences",
TestContext.Current.CancellationToken);
Assert.NotNull(tenantAReloaded);
Assert.Equal("tenant-context-a", tenantAReloaded!.TenantId);
Assert.Equal(new[] { "us-east" }, tenantAReloaded.Regions.ToArray());
Assert.Equal(new[] { "us-prod" }, tenantAReloaded.Environments.ToArray());
Assert.Equal("7d", tenantAReloaded.TimeWindow);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ContextEndpoints_WithoutTenantHeader_ReturnBadRequest()

View File

@@ -13,16 +13,34 @@ public sealed class MigrationAdminEndpointsTests : IClassFixture<PlatformWebAppl
{
private static readonly string[] ExpectedModules =
[
"AdvisoryAI",
"AirGap",
"Attestor",
"Authority",
"BinaryIndex",
"Concelier",
"Eventing",
"Evidence",
"EvidenceLocker",
"Excititor",
"FindingsLedger",
"Graph",
"Notify",
"Orchestrator",
"Platform",
"PluginRegistry",
"Policy",
"ReachGraph",
"Remediation",
"SbomLineage",
"Scanner",
"Scheduler",
"TimelineIndexer"
"Signals",
"TimelineIndexer",
"Unknowns",
"Verdict",
"VexHub",
"VexLens"
];
private readonly PlatformWebApplicationFactory _factory;
@@ -139,4 +157,18 @@ public sealed class MigrationAdminEndpointsTests : IClassFixture<PlatformWebAppl
Assert.NotNull(problem);
Assert.Equal("Release migration approval required", problem!.Title);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Modules_WithoutTenantHeader_RemainsAccessibleAsSystemEndpoint()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "migration-admin-system-test");
var response = await client.GetAsync(
"/api/v1/admin/migrations/modules",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}

View File

@@ -1,4 +1,5 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using StellaOps.Platform.WebService.Contracts;
using Xunit;
@@ -37,4 +38,19 @@ public sealed class OnboardingEndpointsTests : IClassFixture<PlatformWebApplicat
state.Steps.OrderBy(item => item.Step, System.StringComparer.Ordinal).Select(item => item.Step),
state.Steps.Select(item => item.Step));
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Onboarding_SetupStatusRoute_RejectsCrossTenantAccess()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-onboarding-a");
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-onboarding");
var response = await client.GetAsync(
"/api/v1/platform/tenants/tenant-onboarding-b/setup-status",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}

View File

@@ -0,0 +1,135 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
using StellaOps.Platform.WebService.Services;
using StellaOps.TestKit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class PlatformRequestContextResolverTests
{
private readonly PlatformRequestContextResolver _resolver = new();
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryResolve_WithCanonicalClaim_UsesClaimTenant()
{
var context = CreateContext(
claims:
[
new Claim(StellaOpsClaimTypes.Tenant, "Tenant-A"),
new Claim(StellaOpsClaimTypes.Subject, "subject-a"),
],
headers: new Dictionary<string, string>
{
[StellaOpsHttpHeaderNames.Tenant] = "tenant-a",
});
var success = _resolver.TryResolve(context, out var requestContext, out var error);
Assert.True(success);
Assert.NotNull(requestContext);
Assert.Equal("tenant-a", requestContext!.TenantId);
Assert.Equal("subject-a", requestContext.ActorId);
Assert.Null(error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryResolve_WithConflictingClaimAndHeader_ReturnsTenantConflict()
{
var context = CreateContext(
claims:
[
new Claim(StellaOpsClaimTypes.Tenant, "tenant-a"),
new Claim(StellaOpsClaimTypes.Subject, "subject-a"),
],
headers: new Dictionary<string, string>
{
[StellaOpsHttpHeaderNames.Tenant] = "tenant-b",
});
var success = _resolver.TryResolve(context, out var requestContext, out var error);
Assert.False(success);
Assert.Null(requestContext);
Assert.Equal("tenant_conflict", error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryResolve_WithLegacyTidClaim_UsesLegacyClaimFallback()
{
var context = CreateContext(
claims:
[
new Claim("tid", "legacy-tenant"),
new Claim(StellaOpsClaimTypes.ClientId, "client-a"),
]);
var success = _resolver.TryResolve(context, out var requestContext, out var error);
Assert.True(success);
Assert.NotNull(requestContext);
Assert.Equal("legacy-tenant", requestContext!.TenantId);
Assert.Equal("client-a", requestContext.ActorId);
Assert.Null(error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryResolve_WithConflictingCanonicalAndLegacyHeaders_ReturnsTenantConflict()
{
var context = CreateContext(
headers: new Dictionary<string, string>
{
[StellaOpsHttpHeaderNames.Tenant] = "tenant-a",
["X-Stella-Tenant"] = "tenant-b",
["X-StellaOps-Actor"] = "actor-a",
});
var success = _resolver.TryResolve(context, out var requestContext, out var error);
Assert.False(success);
Assert.Null(requestContext);
Assert.Equal("tenant_conflict", error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryResolve_WithoutTenant_ReturnsTenantMissing()
{
var context = CreateContext(
headers: new Dictionary<string, string>
{
["X-StellaOps-Actor"] = "actor-a",
});
var success = _resolver.TryResolve(context, out var requestContext, out var error);
Assert.False(success);
Assert.Null(requestContext);
Assert.Equal("tenant_missing", error);
}
private static DefaultHttpContext CreateContext(
IReadOnlyList<Claim>? claims = null,
IReadOnlyDictionary<string, string>? headers = null)
{
var context = new DefaultHttpContext();
if (claims is not null)
{
context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
}
if (headers is not null)
{
foreach (var pair in headers)
{
context.Request.Headers[pair.Key] = pair.Value;
}
}
return context;
}
}

View File

@@ -105,4 +105,23 @@ public sealed class QuotaEndpointsTests : IClassFixture<PlatformWebApplicationFa
Assert.NotNull(body);
Assert.Equal("quotaId is required.", body!["error"]?.GetValue<string>());
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Quotas_TenantRoute_RejectsCrossTenantAccess()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-quotas-a");
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-quotas");
var response = await client.GetAsync(
"/api/v1/platform/quotas/tenants/tenant-quotas-b",
TestContext.Current.CancellationToken);
Assert.Equal(System.Net.HttpStatusCode.Forbidden, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken);
Assert.NotNull(body);
Assert.Equal("tenant_forbidden", body!["error"]?.GetValue<string>());
}
}

View File

@@ -148,6 +148,38 @@ public sealed class TopologyReadModelEndpointsTests : IClassFixture<PlatformWebA
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task TopologyEndpoints_IsolateDataAcrossTenantsWithOverlappingReleaseSlugs()
{
using var tenantAClient = CreateTenantClient("tenant-topology-a");
using var tenantBClient = CreateTenantClient("tenant-topology-b");
await SeedReleaseAsync(tenantAClient, "shared-release", "Shared Release", "us-prod", "promotion");
await SeedReleaseAsync(tenantBClient, "shared-release", "Shared Release", "us-prod", "promotion");
var tenantATargets = await tenantAClient.GetFromJsonAsync<PlatformListResponse<TopologyTargetProjection>>(
"/api/v2/topology/targets?limit=200&offset=0",
TestContext.Current.CancellationToken);
var tenantBTargets = await tenantBClient.GetFromJsonAsync<PlatformListResponse<TopologyTargetProjection>>(
"/api/v2/topology/targets?limit=200&offset=0",
TestContext.Current.CancellationToken);
Assert.NotNull(tenantATargets);
Assert.NotNull(tenantBTargets);
Assert.Equal("tenant-topology-a", tenantATargets!.TenantId);
Assert.Equal("tenant-topology-b", tenantBTargets!.TenantId);
Assert.Equal(2, tenantATargets.Items.Count);
Assert.Equal(2, tenantBTargets.Items.Count);
var tenantBTargetIds = tenantBTargets.Items
.Select(item => item.TargetId)
.ToHashSet(StringComparer.Ordinal);
Assert.DoesNotContain(
tenantATargets.Items.Select(item => item.TargetId),
targetId => tenantBTargetIds.Contains(targetId));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TopologyEndpoints_RequireExpectedPolicy()