ui progressing
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class ContextEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public ContextEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RegionsAndEnvironments_ReturnDeterministicOrdering()
|
||||
{
|
||||
using var client = CreateTenantClient("tenant-context-1");
|
||||
|
||||
var regionsFirst = await client.GetFromJsonAsync<PlatformContextRegion[]>(
|
||||
"/api/v2/context/regions",
|
||||
TestContext.Current.CancellationToken);
|
||||
var regionsSecond = await client.GetFromJsonAsync<PlatformContextRegion[]>(
|
||||
"/api/v2/context/regions",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(regionsFirst);
|
||||
Assert.NotNull(regionsSecond);
|
||||
Assert.Equal(
|
||||
new[] { "us-east", "eu-west", "apac" },
|
||||
regionsFirst!.Select(region => region.RegionId).ToArray());
|
||||
Assert.Equal(
|
||||
regionsFirst.Select(region => region.RegionId).ToArray(),
|
||||
regionsSecond!.Select(region => region.RegionId).ToArray());
|
||||
|
||||
var environments = await client.GetFromJsonAsync<PlatformContextEnvironment[]>(
|
||||
"/api/v2/context/environments?regions=eu-west,us-east,eu-west",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(environments);
|
||||
Assert.Equal(
|
||||
new[] { "us-prod", "us-uat", "eu-prod", "eu-stage" },
|
||||
environments!.Select(environment => environment.EnvironmentId).ToArray());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Preferences_DefaultAndRoundTrip_AreDeterministic()
|
||||
{
|
||||
using var client = CreateTenantClient("tenant-context-2");
|
||||
|
||||
var defaults = await client.GetFromJsonAsync<PlatformContextPreferences>(
|
||||
"/api/v2/context/preferences",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(defaults);
|
||||
Assert.Equal(new[] { "us-east", "eu-west", "apac" }, defaults!.Regions.ToArray());
|
||||
Assert.Empty(defaults.Environments);
|
||||
Assert.Equal("24h", defaults.TimeWindow);
|
||||
|
||||
var request = new PlatformContextPreferencesRequest(
|
||||
Regions: new[] { "eu-west", "us-east", "unknown", "US-EAST" },
|
||||
Environments: new[] { "eu-stage", "us-prod", "unknown", "apac-prod", "eu-prod", "US-PROD" },
|
||||
TimeWindow: "7d");
|
||||
|
||||
var response = await client.PutAsJsonAsync(
|
||||
"/api/v2/context/preferences",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var updated = await response.Content.ReadFromJsonAsync<PlatformContextPreferences>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(new[] { "us-east", "eu-west" }, updated!.Regions.ToArray());
|
||||
Assert.Equal(new[] { "us-prod", "eu-prod", "eu-stage" }, updated.Environments.ToArray());
|
||||
Assert.Equal("7d", updated.TimeWindow);
|
||||
|
||||
var stored = await client.GetFromJsonAsync<PlatformContextPreferences>(
|
||||
"/api/v2/context/preferences",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(updated.Regions.ToArray(), stored!.Regions.ToArray());
|
||||
Assert.Equal(updated.Environments.ToArray(), stored.Environments.ToArray());
|
||||
Assert.Equal(updated.TimeWindow, stored.TimeWindow);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ContextEndpoints_WithoutTenantHeader_ReturnBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync(
|
||||
"/api/v2/context/regions",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ContextEndpoints_RequireExpectedPolicies()
|
||||
{
|
||||
var endpoints = _factory.Services
|
||||
.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToArray();
|
||||
|
||||
AssertPolicy(endpoints, "/api/v2/context/regions", "GET", PlatformPolicies.ContextRead);
|
||||
AssertPolicy(endpoints, "/api/v2/context/environments", "GET", PlatformPolicies.ContextRead);
|
||||
AssertPolicy(endpoints, "/api/v2/context/preferences", "GET", PlatformPolicies.ContextRead);
|
||||
AssertPolicy(endpoints, "/api/v2/context/preferences", "PUT", PlatformPolicies.ContextWrite);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(
|
||||
IReadOnlyList<RouteEndpoint> endpoints,
|
||||
string routePattern,
|
||||
string method,
|
||||
string expectedPolicy)
|
||||
{
|
||||
var endpoint = endpoints.Single(candidate =>
|
||||
string.Equals(candidate.RoutePattern.RawText, routePattern, StringComparison.Ordinal)
|
||||
&& candidate.Metadata
|
||||
.GetMetadata<HttpMethodMetadata>()?
|
||||
.HttpMethods
|
||||
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
|
||||
|
||||
var policies = endpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(expectedPolicy, policies);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "context-tests");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class ContextMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration047_DefinesGlobalContextSchemaObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("047_GlobalContextAndFilters.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS platform.context_regions", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS platform.context_environments", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS platform.ui_context_preferences", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (time_window IN ('1h', '24h', '7d', '30d', '90d'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("INSERT INTO platform.context_regions", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("INSERT INTO platform.context_environments", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration047_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index046 = Array.IndexOf(migrationNames, "046_TrustSigningAdministration.sql");
|
||||
var index047 = Array.IndexOf(migrationNames, "047_GlobalContextAndFilters.sql");
|
||||
|
||||
Assert.True(index046 >= 0, "Expected migration 046 to exist.");
|
||||
Assert.True(index047 > index046, "Expected migration 047 to appear after migration 046.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class IntegrationSourceHealthMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration051_DefinesIntegrationSourceHealthProjectionObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("051_IntegrationSourceHealth.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.integration_feed_source_health", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.integration_vex_source_health", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.integration_source_sync_watermarks", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (source_type IN ('advisory_feed'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (source_type IN ('vex_source'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (status IN ('healthy', 'degraded', 'offline'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (freshness IN ('fresh', 'stale', 'unknown'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (statement_format IN ('openvex', 'csaf_vex'))", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration051_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index050 = Array.IndexOf(migrationNames, "050_SecurityDispositionProjection.sql");
|
||||
var index051 = Array.IndexOf(migrationNames, "051_IntegrationSourceHealth.sql");
|
||||
|
||||
Assert.True(index050 >= 0, "Expected migration 050 to exist.");
|
||||
Assert.True(index051 > index050, "Expected migration 051 to appear after migration 050.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class IntegrationsReadModelEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public IntegrationsReadModelEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task IntegrationsEndpoints_ReturnDeterministicFeedAndVexSourceHealth_WithSecurityAndDashboardConsumerMetadata()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
await SeedReleaseAsync(client, "checkout-integrations", "Checkout Integrations", "us-prod", "feed-refresh");
|
||||
await SeedReleaseAsync(client, "billing-integrations", "Billing Integrations", "eu-prod", "vex-sync");
|
||||
|
||||
var feedsFirst = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
|
||||
"/api/v2/integrations/feeds?limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var feedsSecond = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
|
||||
"/api/v2/integrations/feeds?limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(feedsFirst);
|
||||
Assert.NotNull(feedsSecond);
|
||||
Assert.NotEmpty(feedsFirst!.Items);
|
||||
Assert.Equal(
|
||||
feedsFirst.Items.Select(item => item.SourceId).ToArray(),
|
||||
feedsSecond!.Items.Select(item => item.SourceId).ToArray());
|
||||
|
||||
Assert.All(feedsFirst.Items, item =>
|
||||
{
|
||||
Assert.Equal("advisory_feed", item.SourceType);
|
||||
Assert.Contains(item.Status, new[] { "healthy", "degraded", "offline" });
|
||||
Assert.Contains(item.Freshness, new[] { "fresh", "stale", "unknown" });
|
||||
Assert.Contains("security-findings", item.ConsumerDomains);
|
||||
Assert.Contains("dashboard-posture", item.ConsumerDomains);
|
||||
});
|
||||
|
||||
var usFeeds = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
|
||||
"/api/v2/integrations/feeds?region=us-east&sourceType=advisory_feed&limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(usFeeds);
|
||||
Assert.NotEmpty(usFeeds!.Items);
|
||||
Assert.All(usFeeds.Items, item =>
|
||||
{
|
||||
Assert.Equal("us-east", item.Region);
|
||||
Assert.Equal("advisory_feed", item.SourceType);
|
||||
});
|
||||
|
||||
var usProdFeeds = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
|
||||
"/api/v2/integrations/feeds?environment=us-prod&limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(usProdFeeds);
|
||||
Assert.NotEmpty(usProdFeeds!.Items);
|
||||
Assert.All(usProdFeeds.Items, item =>
|
||||
{
|
||||
Assert.Equal("us-prod", item.Environment);
|
||||
Assert.NotNull(item.LastSyncAt);
|
||||
Assert.NotNull(item.FreshnessMinutes);
|
||||
});
|
||||
|
||||
var vexFirst = await client.GetFromJsonAsync<PlatformListResponse<IntegrationVexSourceProjection>>(
|
||||
"/api/v2/integrations/vex-sources?limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var vexSecond = await client.GetFromJsonAsync<PlatformListResponse<IntegrationVexSourceProjection>>(
|
||||
"/api/v2/integrations/vex-sources?limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(vexFirst);
|
||||
Assert.NotNull(vexSecond);
|
||||
Assert.NotEmpty(vexFirst!.Items);
|
||||
Assert.Equal(
|
||||
vexFirst.Items.Select(item => item.SourceId).ToArray(),
|
||||
vexSecond!.Items.Select(item => item.SourceId).ToArray());
|
||||
|
||||
Assert.All(vexFirst.Items, item =>
|
||||
{
|
||||
Assert.Equal("vex_source", item.SourceType);
|
||||
Assert.Equal("openvex", item.StatementFormat);
|
||||
Assert.Contains(item.Status, new[] { "healthy", "degraded", "offline" });
|
||||
Assert.Contains(item.Freshness, new[] { "fresh", "stale", "unknown" });
|
||||
Assert.Contains("security-disposition", item.ConsumerDomains);
|
||||
Assert.Contains("dashboard-posture", item.ConsumerDomains);
|
||||
Assert.True(item.DocumentCount24h >= 20);
|
||||
});
|
||||
|
||||
var euVex = await client.GetFromJsonAsync<PlatformListResponse<IntegrationVexSourceProjection>>(
|
||||
"/api/v2/integrations/vex-sources?environment=eu-prod&limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(euVex);
|
||||
Assert.NotEmpty(euVex!.Items);
|
||||
Assert.All(euVex.Items, item => Assert.Equal("eu-prod", item.Environment));
|
||||
|
||||
var offlineVex = await client.GetFromJsonAsync<PlatformListResponse<IntegrationVexSourceProjection>>(
|
||||
"/api/v2/integrations/vex-sources?environment=us-uat&status=offline&limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(offlineVex);
|
||||
Assert.NotEmpty(offlineVex!.Items);
|
||||
Assert.All(offlineVex.Items, item =>
|
||||
{
|
||||
Assert.Equal("us-uat", item.Environment);
|
||||
Assert.Equal("offline", item.Status);
|
||||
Assert.Equal("unknown", item.Freshness);
|
||||
Assert.Null(item.LastSyncAt);
|
||||
});
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task IntegrationsEndpoints_WithoutTenantHeader_ReturnBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v2/integrations/feeds", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IntegrationsEndpoints_RequireExpectedPolicies()
|
||||
{
|
||||
var endpoints = _factory.Services
|
||||
.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToArray();
|
||||
|
||||
AssertPolicy(endpoints, "/api/v2/integrations/feeds", "GET", PlatformPolicies.IntegrationsRead);
|
||||
AssertPolicy(endpoints, "/api/v2/integrations/vex-sources", "GET", PlatformPolicies.IntegrationsVexRead);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(
|
||||
IReadOnlyList<RouteEndpoint> endpoints,
|
||||
string routePattern,
|
||||
string method,
|
||||
string expectedPolicy)
|
||||
{
|
||||
static string NormalizePattern(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "/" : value.TrimEnd('/');
|
||||
|
||||
var expectedPattern = NormalizePattern(routePattern);
|
||||
var endpoint = endpoints.Single(candidate =>
|
||||
string.Equals(
|
||||
NormalizePattern(candidate.RoutePattern.RawText ?? string.Empty),
|
||||
expectedPattern,
|
||||
StringComparison.Ordinal)
|
||||
&& candidate.Metadata
|
||||
.GetMetadata<HttpMethodMetadata>()?
|
||||
.HttpMethods
|
||||
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
|
||||
|
||||
var policies = endpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(expectedPolicy, policies);
|
||||
}
|
||||
|
||||
private static async Task SeedReleaseAsync(
|
||||
HttpClient client,
|
||||
string slug,
|
||||
string name,
|
||||
string targetEnvironment,
|
||||
string reason)
|
||||
{
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(bundle);
|
||||
|
||||
var publishResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
|
||||
new PublishReleaseControlBundleVersionRequest(
|
||||
Changelog: "baseline",
|
||||
Components:
|
||||
[
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.0",
|
||||
ComponentName: $"{slug}-api",
|
||||
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
DeployOrder: 10,
|
||||
MetadataJson: "{\"runtime\":\"docker\"}"),
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.1",
|
||||
ComponentName: $"{slug}-worker",
|
||||
ImageDigest: "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
DeployOrder: 20,
|
||||
MetadataJson: "{\"runtime\":\"compose\"}")
|
||||
]),
|
||||
TestContext.Current.CancellationToken);
|
||||
publishResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(version);
|
||||
|
||||
var materializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
|
||||
TestContext.Current.CancellationToken);
|
||||
materializeResponse.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "integrations-v2-tests");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class LegacyAliasCompatibilityTelemetryTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public LegacyAliasCompatibilityTelemetryTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CriticalPack22Surfaces_ExposeV1Aliases_AndEmitDeterministicDeprecationTelemetry()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
var telemetry = _factory.Services.GetRequiredService<LegacyAliasTelemetry>();
|
||||
telemetry.Clear();
|
||||
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
await SeedReleaseAsync(client, "alias-release", "Alias Release", "us-prod", "alias-check");
|
||||
|
||||
var v1Context = await client.GetAsync("/api/v1/context/regions", TestContext.Current.CancellationToken);
|
||||
var v2Context = await client.GetAsync("/api/v2/context/regions", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, v1Context.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, v2Context.StatusCode);
|
||||
|
||||
var v1Releases = await client.GetFromJsonAsync<PlatformListResponse<ReleaseProjection>>(
|
||||
"/api/v1/releases?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var v2Releases = await client.GetFromJsonAsync<PlatformListResponse<ReleaseProjection>>(
|
||||
"/api/v2/releases?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(v1Releases);
|
||||
Assert.NotNull(v2Releases);
|
||||
Assert.NotEmpty(v1Releases!.Items);
|
||||
Assert.NotEmpty(v2Releases!.Items);
|
||||
|
||||
var v1Runs = await client.GetFromJsonAsync<PlatformListResponse<ReleaseRunProjection>>(
|
||||
"/api/v1/releases/runs?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var v2Runs = await client.GetFromJsonAsync<PlatformListResponse<ReleaseRunProjection>>(
|
||||
"/api/v2/releases/runs?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(v1Runs);
|
||||
Assert.NotNull(v2Runs);
|
||||
Assert.NotEmpty(v1Runs!.Items);
|
||||
Assert.NotEmpty(v2Runs!.Items);
|
||||
|
||||
var sampleRunId = v1Runs.Items[0].RunId;
|
||||
var v1RunTimeline = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunTimelineProjection>>(
|
||||
$"/api/v1/releases/runs/{sampleRunId}/timeline",
|
||||
TestContext.Current.CancellationToken);
|
||||
var v2RunTimeline = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunTimelineProjection>>(
|
||||
$"/api/v2/releases/runs/{sampleRunId}/timeline",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(v1RunTimeline);
|
||||
Assert.NotNull(v2RunTimeline);
|
||||
Assert.Equal(sampleRunId, v1RunTimeline!.Item.RunId);
|
||||
Assert.Equal(sampleRunId, v2RunTimeline!.Item.RunId);
|
||||
|
||||
var v1Topology = await client.GetFromJsonAsync<PlatformListResponse<TopologyRegionProjection>>(
|
||||
"/api/v1/topology/regions?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var v2Topology = await client.GetFromJsonAsync<PlatformListResponse<TopologyRegionProjection>>(
|
||||
"/api/v2/topology/regions?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(v1Topology);
|
||||
Assert.NotNull(v2Topology);
|
||||
Assert.NotEmpty(v1Topology!.Items);
|
||||
Assert.NotEmpty(v2Topology!.Items);
|
||||
|
||||
var v1Security = await client.GetFromJsonAsync<SecurityFindingsResponse>(
|
||||
"/api/v1/security/findings?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var v2Security = await client.GetFromJsonAsync<SecurityFindingsResponse>(
|
||||
"/api/v2/security/findings?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(v1Security);
|
||||
Assert.NotNull(v2Security);
|
||||
Assert.NotEmpty(v1Security!.Items);
|
||||
Assert.NotEmpty(v2Security!.Items);
|
||||
|
||||
var v1Integrations = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
|
||||
"/api/v1/integrations/feeds?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var v2Integrations = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
|
||||
"/api/v2/integrations/feeds?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(v1Integrations);
|
||||
Assert.NotNull(v2Integrations);
|
||||
Assert.NotEmpty(v1Integrations!.Items);
|
||||
Assert.NotEmpty(v2Integrations!.Items);
|
||||
|
||||
var usage = telemetry.Snapshot();
|
||||
Assert.Contains(usage, item =>
|
||||
string.Equals(item.AliasRoute, "/api/v1/context/regions", StringComparison.Ordinal)
|
||||
&& string.Equals(item.CanonicalRoute, "/api/v2/context/regions", StringComparison.Ordinal)
|
||||
&& string.Equals(item.EventKey, "alias_get_api_v1_context_regions", StringComparison.Ordinal));
|
||||
Assert.Contains(usage, item =>
|
||||
string.Equals(item.AliasRoute, "/api/v1/releases", StringComparison.Ordinal)
|
||||
&& string.Equals(item.CanonicalRoute, "/api/v2/releases", StringComparison.Ordinal)
|
||||
&& string.Equals(item.EventKey, "alias_get_api_v1_releases", StringComparison.Ordinal));
|
||||
Assert.Contains(usage, item =>
|
||||
item.AliasRoute.StartsWith("/api/v1/releases/runs", StringComparison.Ordinal)
|
||||
&& item.CanonicalRoute.StartsWith("/api/v2/releases/runs", StringComparison.Ordinal)
|
||||
&& item.EventKey.StartsWith("alias_get_api_v1_releases_runs", StringComparison.Ordinal));
|
||||
Assert.Contains(usage, item =>
|
||||
item.AliasRoute.Contains("/api/v1/releases/runs/", StringComparison.Ordinal)
|
||||
&& item.AliasRoute.EndsWith("/timeline", StringComparison.Ordinal)
|
||||
&& item.CanonicalRoute.Contains("/api/v2/releases/runs/", StringComparison.Ordinal)
|
||||
&& item.CanonicalRoute.EndsWith("/timeline", StringComparison.Ordinal)
|
||||
&& item.EventKey.StartsWith("alias_get_api_v1_releases_runs", StringComparison.Ordinal));
|
||||
Assert.Contains(usage, item =>
|
||||
string.Equals(item.AliasRoute, "/api/v1/topology/regions", StringComparison.Ordinal)
|
||||
&& string.Equals(item.CanonicalRoute, "/api/v2/topology/regions", StringComparison.Ordinal)
|
||||
&& string.Equals(item.EventKey, "alias_get_api_v1_topology_regions", StringComparison.Ordinal));
|
||||
Assert.Contains(usage, item =>
|
||||
string.Equals(item.AliasRoute, "/api/v1/security/findings", StringComparison.Ordinal)
|
||||
&& string.Equals(item.CanonicalRoute, "/api/v2/security/findings", StringComparison.Ordinal)
|
||||
&& string.Equals(item.EventKey, "alias_get_api_v1_security_findings", StringComparison.Ordinal));
|
||||
Assert.Contains(usage, item =>
|
||||
string.Equals(item.AliasRoute, "/api/v1/integrations/feeds", StringComparison.Ordinal)
|
||||
&& string.Equals(item.CanonicalRoute, "/api/v2/integrations/feeds", StringComparison.Ordinal)
|
||||
&& string.Equals(item.EventKey, "alias_get_api_v1_integrations_feeds", StringComparison.Ordinal));
|
||||
|
||||
Assert.DoesNotContain(usage, item =>
|
||||
item.TenantHash is not null
|
||||
&& item.TenantHash.Contains(tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
Assert.All(
|
||||
usage.Where(item => item.AliasRoute.StartsWith("/api/v1/", StringComparison.Ordinal)),
|
||||
item => Assert.StartsWith("alias_", item.EventKey));
|
||||
}
|
||||
|
||||
private static async Task SeedReleaseAsync(
|
||||
HttpClient client,
|
||||
string slug,
|
||||
string name,
|
||||
string targetEnvironment,
|
||||
string reason)
|
||||
{
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(bundle);
|
||||
|
||||
var publishResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
|
||||
new PublishReleaseControlBundleVersionRequest(
|
||||
Changelog: "baseline",
|
||||
Components:
|
||||
[
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.0",
|
||||
ComponentName: $"{slug}-api",
|
||||
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
DeployOrder: 10,
|
||||
MetadataJson: "{\"runtime\":\"docker\"}")
|
||||
]),
|
||||
TestContext.Current.CancellationToken);
|
||||
publishResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(version);
|
||||
|
||||
var materializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
|
||||
TestContext.Current.CancellationToken);
|
||||
materializeResponse.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "legacy-alias-tests");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class ReleaseReadModelEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public ReleaseReadModelEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReleasesListDetailActivityAndApprovals_ReturnDeterministicProjections()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
var checkout = await SeedReleaseAsync(
|
||||
client,
|
||||
"checkout-hotfix",
|
||||
"Checkout Hotfix",
|
||||
"us-prod",
|
||||
"critical-fix");
|
||||
|
||||
var payments = await SeedReleaseAsync(
|
||||
client,
|
||||
"payments-release",
|
||||
"Payments Release",
|
||||
"eu-prod",
|
||||
"policy-review");
|
||||
|
||||
var releasesFirst = await client.GetFromJsonAsync<PlatformListResponse<ReleaseProjection>>(
|
||||
"/api/v2/releases?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var releasesSecond = await client.GetFromJsonAsync<PlatformListResponse<ReleaseProjection>>(
|
||||
"/api/v2/releases?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(releasesFirst);
|
||||
Assert.NotNull(releasesSecond);
|
||||
Assert.True(releasesFirst!.Items.Count >= 2);
|
||||
Assert.Equal(
|
||||
releasesFirst.Items.Select(item => item.ReleaseId).ToArray(),
|
||||
releasesSecond!.Items.Select(item => item.ReleaseId).ToArray());
|
||||
|
||||
var hotfix = releasesFirst.Items.Single(item => item.ReleaseId == checkout.Bundle.Id.ToString("D"));
|
||||
Assert.Equal("hotfix", hotfix.ReleaseType);
|
||||
Assert.Equal("pending_approval", hotfix.Status);
|
||||
Assert.Equal("us-prod", hotfix.TargetEnvironment);
|
||||
Assert.Equal("us-east", hotfix.TargetRegion);
|
||||
|
||||
var detail = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseDetailProjection>>(
|
||||
$"/api/v2/releases/{checkout.Bundle.Id:D}",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(detail);
|
||||
Assert.Equal(checkout.Bundle.Id.ToString("D"), detail!.Item.Summary.ReleaseId);
|
||||
Assert.NotEmpty(detail.Item.Versions);
|
||||
Assert.Contains(detail.Item.Approvals, approval =>
|
||||
string.Equals(approval.TargetEnvironment, "us-prod", StringComparison.Ordinal));
|
||||
|
||||
var activityFirst = await client.GetFromJsonAsync<PlatformListResponse<ReleaseActivityProjection>>(
|
||||
"/api/v2/releases/activity?limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var activitySecond = await client.GetFromJsonAsync<PlatformListResponse<ReleaseActivityProjection>>(
|
||||
"/api/v2/releases/activity?limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(activityFirst);
|
||||
Assert.NotNull(activitySecond);
|
||||
Assert.Contains(activityFirst!.Items, item => item.ReleaseId == checkout.Bundle.Id.ToString("D"));
|
||||
Assert.Contains(activityFirst.Items, item => item.ReleaseId == payments.Bundle.Id.ToString("D"));
|
||||
Assert.Equal(
|
||||
activityFirst.Items.Select(item => item.ActivityId).ToArray(),
|
||||
activitySecond!.Items.Select(item => item.ActivityId).ToArray());
|
||||
|
||||
var approvalsFirst = await client.GetFromJsonAsync<PlatformListResponse<ReleaseApprovalProjection>>(
|
||||
"/api/v2/releases/approvals?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var approvalsSecond = await client.GetFromJsonAsync<PlatformListResponse<ReleaseApprovalProjection>>(
|
||||
"/api/v2/releases/approvals?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(approvalsFirst);
|
||||
Assert.NotNull(approvalsSecond);
|
||||
Assert.True(approvalsFirst!.Items.Count >= 2);
|
||||
Assert.All(approvalsFirst.Items, item => Assert.Equal("pending", item.Status));
|
||||
Assert.Equal(
|
||||
approvalsFirst.Items.Select(item => item.ApprovalId).ToArray(),
|
||||
approvalsSecond!.Items.Select(item => item.ApprovalId).ToArray());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReleasesEndpoints_ApplyRegionEnvironmentAndStatusFilters()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
await SeedReleaseAsync(client, "orders-hotfix", "Orders Hotfix", "us-prod", "critical-fix");
|
||||
await SeedReleaseAsync(client, "inventory-release", "Inventory Release", "eu-prod", "policy-review");
|
||||
|
||||
var usReleases = await client.GetFromJsonAsync<PlatformListResponse<ReleaseProjection>>(
|
||||
"/api/v2/releases?region=us-east&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(usReleases);
|
||||
Assert.NotEmpty(usReleases!.Items);
|
||||
Assert.All(usReleases.Items, item => Assert.Equal("us-east", item.TargetRegion));
|
||||
|
||||
var euActivity = await client.GetFromJsonAsync<PlatformListResponse<ReleaseActivityProjection>>(
|
||||
"/api/v2/releases/activity?environment=eu-prod&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(euActivity);
|
||||
Assert.NotEmpty(euActivity!.Items);
|
||||
Assert.All(euActivity.Items, item => Assert.Equal("eu-prod", item.TargetEnvironment));
|
||||
|
||||
var euApprovals = await client.GetFromJsonAsync<PlatformListResponse<ReleaseApprovalProjection>>(
|
||||
"/api/v2/releases/approvals?status=pending®ion=eu-west&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(euApprovals);
|
||||
Assert.NotEmpty(euApprovals!.Items);
|
||||
Assert.All(euApprovals.Items, item => Assert.Equal("eu-west", item.TargetRegion));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReleasesEndpoints_WithoutTenantHeader_ReturnBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v2/releases", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ReleasesEndpoints_RequireExpectedPolicies()
|
||||
{
|
||||
var endpoints = _factory.Services
|
||||
.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToArray();
|
||||
|
||||
AssertPolicy(endpoints, "/api/v2/releases", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/{releaseId:guid}", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/activity", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/approvals", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(
|
||||
IReadOnlyList<RouteEndpoint> endpoints,
|
||||
string routePattern,
|
||||
string method,
|
||||
string expectedPolicy)
|
||||
{
|
||||
static string NormalizePattern(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "/" : value.TrimEnd('/');
|
||||
|
||||
var expectedPattern = NormalizePattern(routePattern);
|
||||
var endpoint = endpoints.Single(candidate =>
|
||||
string.Equals(
|
||||
NormalizePattern(candidate.RoutePattern.RawText ?? string.Empty),
|
||||
expectedPattern,
|
||||
StringComparison.Ordinal)
|
||||
&& candidate.Metadata
|
||||
.GetMetadata<HttpMethodMetadata>()?
|
||||
.HttpMethods
|
||||
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
|
||||
|
||||
var policies = endpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(expectedPolicy, policies);
|
||||
}
|
||||
|
||||
private static async Task<SeededRelease> SeedReleaseAsync(
|
||||
HttpClient client,
|
||||
string slug,
|
||||
string name,
|
||||
string targetEnvironment,
|
||||
string reason)
|
||||
{
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(bundle);
|
||||
|
||||
var publishResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
|
||||
new PublishReleaseControlBundleVersionRequest(
|
||||
Changelog: "baseline",
|
||||
Components:
|
||||
[
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.0",
|
||||
ComponentName: slug,
|
||||
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
DeployOrder: 10,
|
||||
MetadataJson: "{\"track\":\"stable\"}")
|
||||
]),
|
||||
TestContext.Current.CancellationToken);
|
||||
publishResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(version);
|
||||
|
||||
var materializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
|
||||
TestContext.Current.CancellationToken);
|
||||
materializeResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var run = await materializeResponse.Content.ReadFromJsonAsync<ReleaseControlBundleMaterializationRun>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(run);
|
||||
|
||||
return new SeededRelease(bundle, version, run!);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "releases-v2-tests");
|
||||
return client;
|
||||
}
|
||||
|
||||
private sealed record SeededRelease(
|
||||
ReleaseControlBundleDetail Bundle,
|
||||
ReleaseControlBundleVersionDetail Version,
|
||||
ReleaseControlBundleMaterializationRun Run);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class ReleaseReadModelMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration048_DefinesReleaseReadModelProjectionObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("048_ReleaseReadModels.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.release_read_model", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.release_activity_projection", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.release_approvals_projection", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("correlation_key", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (release_type IN ('standard', 'hotfix'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (approval_status IN ('pending', 'approved', 'rejected'))", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration048_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index047 = Array.IndexOf(migrationNames, "047_GlobalContextAndFilters.sql");
|
||||
var index048 = Array.IndexOf(migrationNames, "048_ReleaseReadModels.sql");
|
||||
|
||||
Assert.True(index047 >= 0, "Expected migration 047 to exist.");
|
||||
Assert.True(index048 > index047, "Expected migration 048 to appear after migration 047.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class ReleaseRunEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory factory;
|
||||
|
||||
public ReleaseRunEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunEndpoints_ReturnDeterministicRunCentricContractsAcrossTabs()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
var hotfix = await SeedReleaseAsync(client, "checkout-hotfix", "Checkout Hotfix", "us-prod", "stale-integrity-window");
|
||||
await SeedReleaseAsync(client, "payments-release", "Payments Release", "eu-prod", "routine-promotion");
|
||||
|
||||
var runsFirst = await client.GetFromJsonAsync<PlatformListResponse<ReleaseRunProjection>>(
|
||||
"/api/v2/releases/runs?limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var runsSecond = await client.GetFromJsonAsync<PlatformListResponse<ReleaseRunProjection>>(
|
||||
"/api/v2/releases/runs?limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(runsFirst);
|
||||
Assert.NotNull(runsSecond);
|
||||
Assert.True(runsFirst!.Items.Count >= 2);
|
||||
Assert.Equal(
|
||||
runsFirst.Items.Select(item => item.RunId).ToArray(),
|
||||
runsSecond!.Items.Select(item => item.RunId).ToArray());
|
||||
|
||||
var hotfixProjection = runsFirst.Items.Single(item => item.RunId == hotfix.Run.RunId.ToString("D"));
|
||||
Assert.Equal("hotfix", hotfixProjection.Lane);
|
||||
Assert.Equal("us-prod", hotfixProjection.TargetEnvironment);
|
||||
Assert.Equal("us-east", hotfixProjection.TargetRegion);
|
||||
Assert.True(hotfixProjection.BlockedByDataIntegrity);
|
||||
|
||||
var detail = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunDetailProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(detail);
|
||||
Assert.Equal(hotfix.Run.RunId.ToString("D"), detail!.Item.RunId);
|
||||
Assert.Equal("connect", detail.Item.Process[0].StepId);
|
||||
Assert.Equal(5, detail.Item.Process.Count);
|
||||
|
||||
var timeline = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunTimelineProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/timeline",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(timeline);
|
||||
Assert.Equal(hotfix.Run.RunId.ToString("D"), timeline!.Item.RunId);
|
||||
Assert.True(timeline.Item.Events.Count >= 6);
|
||||
Assert.Contains(timeline.Item.Correlations, item => string.Equals(item.Type, "snapshot_id", StringComparison.Ordinal));
|
||||
|
||||
var gate = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunGateDecisionProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/gate-decision",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(gate);
|
||||
Assert.Equal(hotfix.Run.RunId.ToString("D"), gate!.Item.RunId);
|
||||
Assert.NotEmpty(gate.Item.PolicyPackVersion);
|
||||
Assert.NotEmpty(gate.Item.MachineReasonCodes);
|
||||
|
||||
var approvals = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunApprovalsProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/approvals",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(approvals);
|
||||
Assert.Equal(new[] { 1, 2 }, approvals!.Item.Checkpoints.Select(item => item.Order).ToArray());
|
||||
|
||||
var deployments = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunDeploymentsProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/deployments",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(deployments);
|
||||
Assert.NotEmpty(deployments!.Item.Targets);
|
||||
Assert.NotEmpty(deployments.Item.RollbackTriggers);
|
||||
|
||||
var securityInputs = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunSecurityInputsProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/security-inputs",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(securityInputs);
|
||||
Assert.InRange(securityInputs!.Item.ReachabilityCoveragePercent, 0, 100);
|
||||
Assert.NotEmpty(securityInputs.Item.Drilldowns);
|
||||
|
||||
var evidence = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunEvidenceProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/evidence",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Contains("capsule-", evidence!.Item.DecisionCapsuleId, StringComparison.Ordinal);
|
||||
|
||||
var rollback = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunRollbackProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/rollback",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(rollback);
|
||||
Assert.NotEmpty(rollback!.Item.KnownGoodReferences);
|
||||
|
||||
var replay = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunReplayProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/replay",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(replay);
|
||||
Assert.Contains(replay!.Item.Verdict, new[] { "match", "mismatch", "not_available" });
|
||||
|
||||
var audit = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunAuditProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/audit",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(audit);
|
||||
Assert.NotEmpty(audit!.Item.Entries);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunEndpoints_WithoutTenantHeader_ReturnBadRequest()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v2/releases/runs", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RunEndpoints_RequireExpectedPolicies()
|
||||
{
|
||||
var endpoints = factory.Services
|
||||
.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToArray();
|
||||
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/timeline", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/gate-decision", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/approvals", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/deployments", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/security-inputs", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/evidence", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/rollback", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/replay", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/audit", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(
|
||||
IReadOnlyList<RouteEndpoint> endpoints,
|
||||
string routePattern,
|
||||
string method,
|
||||
string expectedPolicy)
|
||||
{
|
||||
static string NormalizePattern(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "/" : value.TrimEnd('/');
|
||||
|
||||
var expectedPattern = NormalizePattern(routePattern);
|
||||
var endpoint = endpoints.Single(candidate =>
|
||||
string.Equals(
|
||||
NormalizePattern(candidate.RoutePattern.RawText ?? string.Empty),
|
||||
expectedPattern,
|
||||
StringComparison.Ordinal)
|
||||
&& candidate.Metadata
|
||||
.GetMetadata<HttpMethodMetadata>()?
|
||||
.HttpMethods
|
||||
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
|
||||
|
||||
var policies = endpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(expectedPolicy, policies);
|
||||
}
|
||||
|
||||
private static async Task<SeededRelease> SeedReleaseAsync(
|
||||
HttpClient client,
|
||||
string slug,
|
||||
string name,
|
||||
string targetEnvironment,
|
||||
string reason)
|
||||
{
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(bundle);
|
||||
|
||||
var publishResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
|
||||
new PublishReleaseControlBundleVersionRequest(
|
||||
Changelog: "baseline",
|
||||
Components:
|
||||
[
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.0",
|
||||
ComponentName: slug,
|
||||
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
DeployOrder: 10,
|
||||
MetadataJson: "{\"track\":\"stable\"}")
|
||||
]),
|
||||
TestContext.Current.CancellationToken);
|
||||
publishResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(version);
|
||||
|
||||
var materializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
|
||||
TestContext.Current.CancellationToken);
|
||||
materializeResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var run = await materializeResponse.Content.ReadFromJsonAsync<ReleaseControlBundleMaterializationRun>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(run);
|
||||
|
||||
return new SeededRelease(bundle, version, run!);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "release-runs-v2-tests");
|
||||
return client;
|
||||
}
|
||||
|
||||
private sealed record SeededRelease(
|
||||
ReleaseControlBundleDetail Bundle,
|
||||
ReleaseControlBundleVersionDetail Version,
|
||||
ReleaseControlBundleMaterializationRun Run);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class RunApprovalCheckpointsMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration054_DefinesRunApprovalCheckpointObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("054_RunApprovalCheckpoints.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.run_approval_checkpoints", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ux_release_run_approval_checkpoints_order", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("signature_algorithm", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("signature_value", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (status IN ('pending', 'approved', 'rejected', 'skipped'))", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration054_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index053 = Array.IndexOf(migrationNames, "053_RunGateDecisionLedger.sql");
|
||||
var index054 = Array.IndexOf(migrationNames, "054_RunApprovalCheckpoints.sql");
|
||||
|
||||
Assert.True(index053 >= 0, "Expected migration 053 to exist.");
|
||||
Assert.True(index054 > index053, "Expected migration 054 to appear after migration 053.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class RunCapsuleReplayLinkageMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration056_DefinesRunCapsuleReplayLinkageObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("056_RunCapsuleReplayLinkage.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.run_capsule_replay_linkage", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ux_release_run_capsule_replay_linkage_run", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ix_release_run_capsule_replay_linkage_verdict", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (signature_status IN ('signed', 'unsigned', 'invalid'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (replay_verdict IN ('match', 'mismatch', 'not_available'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (replay_verdict <> 'mismatch' OR replay_mismatch_report_ref IS NOT NULL)", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration056_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index055 = Array.IndexOf(migrationNames, "055_RunDeploymentTimeline.sql");
|
||||
var index056 = Array.IndexOf(migrationNames, "056_RunCapsuleReplayLinkage.sql");
|
||||
|
||||
Assert.True(index055 >= 0, "Expected migration 055 to exist.");
|
||||
Assert.True(index056 > index055, "Expected migration 056 to appear after migration 055.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class RunDeploymentTimelineMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration055_DefinesRunDeploymentTimelineObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("055_RunDeploymentTimeline.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.run_deployment_timeline", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ix_release_run_deployment_timeline_run", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ix_release_run_deployment_timeline_filters", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (strategy IN ('canary', 'rolling', 'blue_green', 'recreate'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (phase IN ('queued', 'precheck', 'deploying', 'verifying', 'completed', 'rollback'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'rolled_back'))", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration055_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index054 = Array.IndexOf(migrationNames, "054_RunApprovalCheckpoints.sql");
|
||||
var index055 = Array.IndexOf(migrationNames, "055_RunDeploymentTimeline.sql");
|
||||
|
||||
Assert.True(index054 >= 0, "Expected migration 054 to exist.");
|
||||
Assert.True(index055 > index054, "Expected migration 055 to appear after migration 054.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class RunGateDecisionLedgerMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration053_DefinesRunGateDecisionLedgerObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("053_RunGateDecisionLedger.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.run_gate_decision_ledger", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("risk_budget_delta", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("risk_budget_contributors", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (verdict IN ('allow', 'review', 'block'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (staleness_verdict IN ('fresh', 'stale', 'unknown'))", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration053_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index052 = Array.IndexOf(migrationNames, "052_RunInputSnapshots.sql");
|
||||
var index053 = Array.IndexOf(migrationNames, "053_RunGateDecisionLedger.sql");
|
||||
|
||||
Assert.True(index052 >= 0, "Expected migration 052 to exist.");
|
||||
Assert.True(index053 > index052, "Expected migration 053 to appear after migration 052.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class RunInputSnapshotsMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration052_DefinesRunInputSnapshotObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("052_RunInputSnapshots.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.run_input_snapshots", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ux_release_run_input_snapshots_tenant_run", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ix_release_run_input_snapshots_tenant_bundle_version", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (feed_freshness_status IN ('fresh', 'stale', 'unknown'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (reachability_coverage_percent BETWEEN 0 AND 100)", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration052_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index051 = Array.IndexOf(migrationNames, "051_IntegrationSourceHealth.sql");
|
||||
var index052 = Array.IndexOf(migrationNames, "052_RunInputSnapshots.sql");
|
||||
|
||||
Assert.True(index051 >= 0, "Expected migration 051 to exist.");
|
||||
Assert.True(index052 > index051, "Expected migration 052 to appear after migration 051.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class SecurityDispositionMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration050_DefinesSecurityDispositionProjectionObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("050_SecurityDispositionProjection.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.security_finding_projection", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.security_disposition_projection", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.security_sbom_component_projection", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.security_sbom_graph_projection", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (vex_status IN ('affected', 'not_affected', 'under_investigation'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (exception_status IN ('none', 'pending', 'approved', 'rejected'))", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration050_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index049 = Array.IndexOf(migrationNames, "049_TopologyInventory.sql");
|
||||
var index050 = Array.IndexOf(migrationNames, "050_SecurityDispositionProjection.sql");
|
||||
|
||||
Assert.True(index049 >= 0, "Expected migration 049 to exist.");
|
||||
Assert.True(index050 > index049, "Expected migration 050 to appear after migration 049.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class SecurityReadModelEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public SecurityReadModelEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SecurityEndpoints_ReturnDeterministicFindingsDispositionAndSbomExplorer()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
var checkout = await SeedReleaseAsync(client, "checkout-security-release", "Checkout Security Release", "us-prod", "security-exception");
|
||||
var billing = await SeedReleaseAsync(client, "billing-security-release", "Billing Security Release", "eu-prod", "policy-review");
|
||||
|
||||
var findingsFirst = await client.GetFromJsonAsync<SecurityFindingsResponse>(
|
||||
"/api/v2/security/findings?pivot=component&limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var findingsSecond = await client.GetFromJsonAsync<SecurityFindingsResponse>(
|
||||
"/api/v2/security/findings?pivot=component&limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(findingsFirst);
|
||||
Assert.NotNull(findingsSecond);
|
||||
Assert.NotEmpty(findingsFirst!.Items);
|
||||
Assert.NotEmpty(findingsFirst.PivotBuckets);
|
||||
Assert.NotEmpty(findingsFirst.Facets);
|
||||
Assert.Equal(
|
||||
findingsFirst.Items.Select(item => item.FindingId).ToArray(),
|
||||
findingsSecond!.Items.Select(item => item.FindingId).ToArray());
|
||||
|
||||
var usFindings = await client.GetFromJsonAsync<SecurityFindingsResponse>(
|
||||
"/api/v2/security/findings?region=us-east&severity=critical&limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(usFindings);
|
||||
Assert.All(usFindings!.Items, item => Assert.Equal("us-east", item.Region));
|
||||
Assert.All(usFindings.Items, item => Assert.Equal("critical", item.Severity));
|
||||
|
||||
var dispositionList = await client.GetFromJsonAsync<PlatformListResponse<SecurityDispositionProjection>>(
|
||||
"/api/v2/security/disposition?limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(dispositionList);
|
||||
Assert.NotEmpty(dispositionList!.Items);
|
||||
Assert.All(dispositionList.Items, item =>
|
||||
{
|
||||
Assert.Equal("vex", item.Vex.SourceModel);
|
||||
Assert.Equal("exceptions", item.Exception.SourceModel);
|
||||
});
|
||||
|
||||
var firstFindingId = dispositionList.Items[0].FindingId;
|
||||
var dispositionDetail = await client.GetFromJsonAsync<PlatformItemResponse<SecurityDispositionProjection>>(
|
||||
$"/api/v2/security/disposition/{firstFindingId}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(dispositionDetail);
|
||||
Assert.Equal(firstFindingId, dispositionDetail!.Item.FindingId);
|
||||
|
||||
var sbomTable = await client.GetFromJsonAsync<SecuritySbomExplorerResponse>(
|
||||
"/api/v2/security/sbom-explorer?mode=table&limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(sbomTable);
|
||||
Assert.Equal("table", sbomTable!.Mode);
|
||||
Assert.NotEmpty(sbomTable.Table);
|
||||
Assert.Empty(sbomTable.GraphNodes);
|
||||
Assert.Empty(sbomTable.GraphEdges);
|
||||
|
||||
var sbomGraph = await client.GetFromJsonAsync<SecuritySbomExplorerResponse>(
|
||||
"/api/v2/security/sbom-explorer?mode=graph&limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(sbomGraph);
|
||||
Assert.Equal("graph", sbomGraph!.Mode);
|
||||
Assert.NotEmpty(sbomGraph.GraphNodes);
|
||||
Assert.NotEmpty(sbomGraph.GraphEdges);
|
||||
|
||||
var sbomDiff = await client.GetFromJsonAsync<SecuritySbomExplorerResponse>(
|
||||
$"/api/v2/security/sbom-explorer?mode=diff&leftReleaseId={checkout.Bundle.Id:D}&rightReleaseId={billing.Bundle.Id:D}&limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(sbomDiff);
|
||||
Assert.Equal("diff", sbomDiff!.Mode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SecurityEndpoints_WithoutTenantHeader_ReturnBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v2/security/findings", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SecurityEndpoints_RequireExpectedPolicies_AndDoNotExposeCombinedWriteRoute()
|
||||
{
|
||||
var endpoints = _factory.Services
|
||||
.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToArray();
|
||||
|
||||
AssertPolicy(endpoints, "/api/v2/security/findings", "GET", PlatformPolicies.SecurityRead);
|
||||
AssertPolicy(endpoints, "/api/v2/security/disposition", "GET", PlatformPolicies.SecurityRead);
|
||||
AssertPolicy(endpoints, "/api/v2/security/disposition/{findingId}", "GET", PlatformPolicies.SecurityRead);
|
||||
AssertPolicy(endpoints, "/api/v2/security/sbom-explorer", "GET", PlatformPolicies.SecurityRead);
|
||||
|
||||
var hasCombinedWrite = endpoints.Any(candidate =>
|
||||
string.Equals(candidate.RoutePattern.RawText, "/api/v2/security/disposition/exceptions", StringComparison.Ordinal)
|
||||
&& candidate.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains("POST", StringComparer.OrdinalIgnoreCase) == true);
|
||||
Assert.False(hasCombinedWrite);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(
|
||||
IReadOnlyList<RouteEndpoint> endpoints,
|
||||
string routePattern,
|
||||
string method,
|
||||
string expectedPolicy)
|
||||
{
|
||||
static string NormalizePattern(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "/" : value.TrimEnd('/');
|
||||
|
||||
var expectedPattern = NormalizePattern(routePattern);
|
||||
var endpoint = endpoints.Single(candidate =>
|
||||
string.Equals(
|
||||
NormalizePattern(candidate.RoutePattern.RawText ?? string.Empty),
|
||||
expectedPattern,
|
||||
StringComparison.Ordinal)
|
||||
&& candidate.Metadata
|
||||
.GetMetadata<HttpMethodMetadata>()?
|
||||
.HttpMethods
|
||||
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
|
||||
|
||||
var policies = endpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(expectedPolicy, policies);
|
||||
}
|
||||
|
||||
private static async Task<SeededRelease> SeedReleaseAsync(
|
||||
HttpClient client,
|
||||
string slug,
|
||||
string name,
|
||||
string targetEnvironment,
|
||||
string reason)
|
||||
{
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(bundle);
|
||||
|
||||
var publishResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
|
||||
new PublishReleaseControlBundleVersionRequest(
|
||||
Changelog: "baseline",
|
||||
Components:
|
||||
[
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.0",
|
||||
ComponentName: $"{slug}-api",
|
||||
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
DeployOrder: 10,
|
||||
MetadataJson: "{\"runtime\":\"docker\"}"),
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.1",
|
||||
ComponentName: $"{slug}-worker",
|
||||
ImageDigest: "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
DeployOrder: 20,
|
||||
MetadataJson: "{\"runtime\":\"compose\"}")
|
||||
]),
|
||||
TestContext.Current.CancellationToken);
|
||||
publishResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(version);
|
||||
|
||||
var materializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
|
||||
TestContext.Current.CancellationToken);
|
||||
materializeResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var run = await materializeResponse.Content.ReadFromJsonAsync<ReleaseControlBundleMaterializationRun>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(run);
|
||||
|
||||
return new SeededRelease(bundle, version, run!);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "security-v2-tests");
|
||||
return client;
|
||||
}
|
||||
|
||||
private sealed record SeededRelease(
|
||||
ReleaseControlBundleDetail Bundle,
|
||||
ReleaseControlBundleVersionDetail Version,
|
||||
ReleaseControlBundleMaterializationRun Run);
|
||||
}
|
||||
@@ -7,6 +7,12 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| --- | --- | --- |
|
||||
| PACK-ADM-01-T | DONE | Added/verified `PackAdapterEndpointsTests` coverage for `/api/v1/administration/{summary,identity-access,tenant-branding,notifications,usage-limits,policy-governance,trust-signing,system}` and deterministic alias ordering assertions. |
|
||||
| PACK-ADM-02-T | DONE | Added `AdministrationTrustSigningMutationEndpointsTests` covering trust-owner key/issuer/certificate/transparency lifecycle plus route metadata policy bindings for `platform.trust.read`, `platform.trust.write`, and `platform.trust.admin`. |
|
||||
| B22-01-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `ContextEndpointsTests` + `ContextMigrationScriptTests` for `/api/v2/context/*` deterministic ordering, preference round-trip behavior, and migration `047` coverage. |
|
||||
| B22-02-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `ReleaseReadModelEndpointsTests` + `ReleaseReadModelMigrationScriptTests` for `/api/v2/releases{,/activity,/approvals,/{releaseId}}` deterministic projection behavior and migration `048` coverage. |
|
||||
| B22-03-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `TopologyReadModelEndpointsTests` + `TopologyInventoryMigrationScriptTests` for `/api/v2/topology/*` deterministic ordering/filter behavior and migration `049` coverage. |
|
||||
| B22-04-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `SecurityReadModelEndpointsTests` + `SecurityDispositionMigrationScriptTests` for `/api/v2/security/{findings,disposition,sbom-explorer}` deterministic behavior, policy metadata, write-boundary checks, and migration `050` coverage. |
|
||||
| B22-05-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `IntegrationsReadModelEndpointsTests` + `IntegrationSourceHealthMigrationScriptTests` for `/api/v2/integrations/{feeds,vex-sources}` deterministic behavior, policy metadata, consumer compatibility, and migration `051` coverage. |
|
||||
| B22-06-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added compatibility+telemetry contract tests covering both `/api/v1/*` aliases and `/api/v2/*` canonical routes for critical Pack 22 surfaces. |
|
||||
| AUDIT-0762-M | DONE | Revalidated 2026-01-07 (test project). |
|
||||
| AUDIT-0762-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0762-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class TopologyInventoryMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration049_DefinesTopologyInventoryProjectionObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("049_TopologyInventory.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_region_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_environment_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_target_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_host_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_agent_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_promotion_path_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_workflow_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_gate_profile_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_sync_watermarks", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (target_type IN ('docker_host', 'compose_host', 'ecs_service', 'nomad_job', 'ssh_host'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (path_status IN ('idle', 'pending', 'running', 'failed', 'succeeded'))", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration049_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index048 = Array.IndexOf(migrationNames, "048_ReleaseReadModels.sql");
|
||||
var index049 = Array.IndexOf(migrationNames, "049_TopologyInventory.sql");
|
||||
|
||||
Assert.True(index048 >= 0, "Expected migration 048 to exist.");
|
||||
Assert.True(index049 > index048, "Expected migration 049 to appear after migration 048.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class TopologyReadModelEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public TopologyReadModelEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TopologyEndpoints_ReturnDeterministicInventoryAndSupportFilters()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
await SeedReleaseAsync(client, "orders-release", "Orders Release", "us-prod", "promotion");
|
||||
await SeedReleaseAsync(client, "billing-release", "Billing Release", "eu-prod", "promotion");
|
||||
|
||||
var regions = await client.GetFromJsonAsync<PlatformListResponse<TopologyRegionProjection>>(
|
||||
"/api/v2/topology/regions?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(regions);
|
||||
Assert.Equal(new[] { "us-east", "eu-west", "apac" }, regions!.Items.Select(item => item.RegionId).ToArray());
|
||||
|
||||
var environments = await client.GetFromJsonAsync<PlatformListResponse<TopologyEnvironmentProjection>>(
|
||||
"/api/v2/topology/environments?region=us-east,eu-west&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(environments);
|
||||
Assert.Equal(
|
||||
new[] { "us-prod", "us-uat", "eu-prod", "eu-stage" },
|
||||
environments!.Items.Select(item => item.EnvironmentId).ToArray());
|
||||
|
||||
var targetsFirst = await client.GetFromJsonAsync<PlatformListResponse<TopologyTargetProjection>>(
|
||||
"/api/v2/topology/targets?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var targetsSecond = await client.GetFromJsonAsync<PlatformListResponse<TopologyTargetProjection>>(
|
||||
"/api/v2/topology/targets?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(targetsFirst);
|
||||
Assert.NotNull(targetsSecond);
|
||||
Assert.NotEmpty(targetsFirst!.Items);
|
||||
Assert.Equal(
|
||||
targetsFirst.Items.Select(item => item.TargetId).ToArray(),
|
||||
targetsSecond!.Items.Select(item => item.TargetId).ToArray());
|
||||
|
||||
var hostsFirst = await client.GetFromJsonAsync<PlatformListResponse<TopologyHostProjection>>(
|
||||
"/api/v2/topology/hosts?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var hostsSecond = await client.GetFromJsonAsync<PlatformListResponse<TopologyHostProjection>>(
|
||||
"/api/v2/topology/hosts?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(hostsFirst);
|
||||
Assert.NotNull(hostsSecond);
|
||||
Assert.NotEmpty(hostsFirst!.Items);
|
||||
Assert.Equal(
|
||||
hostsFirst.Items.Select(item => item.HostId).ToArray(),
|
||||
hostsSecond!.Items.Select(item => item.HostId).ToArray());
|
||||
|
||||
var agentsFirst = await client.GetFromJsonAsync<PlatformListResponse<TopologyAgentProjection>>(
|
||||
"/api/v2/topology/agents?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var agentsSecond = await client.GetFromJsonAsync<PlatformListResponse<TopologyAgentProjection>>(
|
||||
"/api/v2/topology/agents?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(agentsFirst);
|
||||
Assert.NotNull(agentsSecond);
|
||||
Assert.NotEmpty(agentsFirst!.Items);
|
||||
Assert.Equal(
|
||||
agentsFirst.Items.Select(item => item.AgentId).ToArray(),
|
||||
agentsSecond!.Items.Select(item => item.AgentId).ToArray());
|
||||
|
||||
var paths = await client.GetFromJsonAsync<PlatformListResponse<TopologyPromotionPathProjection>>(
|
||||
"/api/v2/topology/promotion-paths?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(paths);
|
||||
Assert.NotEmpty(paths!.Items);
|
||||
|
||||
var workflows = await client.GetFromJsonAsync<PlatformListResponse<TopologyWorkflowProjection>>(
|
||||
"/api/v2/topology/workflows?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(workflows);
|
||||
Assert.NotEmpty(workflows!.Items);
|
||||
|
||||
var profiles = await client.GetFromJsonAsync<PlatformListResponse<TopologyGateProfileProjection>>(
|
||||
"/api/v2/topology/gate-profiles?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(profiles);
|
||||
Assert.NotEmpty(profiles!.Items);
|
||||
|
||||
var usTargets = await client.GetFromJsonAsync<PlatformListResponse<TopologyTargetProjection>>(
|
||||
"/api/v2/topology/targets?region=us-east&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(usTargets);
|
||||
Assert.NotEmpty(usTargets!.Items);
|
||||
Assert.All(usTargets.Items, item => Assert.Equal("us-east", item.RegionId));
|
||||
|
||||
var euHosts = await client.GetFromJsonAsync<PlatformListResponse<TopologyHostProjection>>(
|
||||
"/api/v2/topology/hosts?environment=eu-prod&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(euHosts);
|
||||
Assert.NotEmpty(euHosts!.Items);
|
||||
Assert.All(euHosts.Items, item => Assert.Equal("eu-prod", item.EnvironmentId));
|
||||
|
||||
var usPaths = await client.GetFromJsonAsync<PlatformListResponse<TopologyPromotionPathProjection>>(
|
||||
"/api/v2/topology/promotion-paths?environment=us-prod&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(usPaths);
|
||||
Assert.NotEmpty(usPaths!.Items);
|
||||
Assert.All(usPaths.Items, item =>
|
||||
Assert.True(
|
||||
string.Equals(item.SourceEnvironmentId, "us-prod", StringComparison.Ordinal)
|
||||
|| string.Equals(item.TargetEnvironmentId, "us-prod", StringComparison.Ordinal)));
|
||||
|
||||
var euWorkflows = await client.GetFromJsonAsync<PlatformListResponse<TopologyWorkflowProjection>>(
|
||||
"/api/v2/topology/workflows?environment=eu-prod&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(euWorkflows);
|
||||
Assert.NotEmpty(euWorkflows!.Items);
|
||||
Assert.All(euWorkflows.Items, item => Assert.Equal("eu-prod", item.EnvironmentId));
|
||||
|
||||
var euProfiles = await client.GetFromJsonAsync<PlatformListResponse<TopologyGateProfileProjection>>(
|
||||
"/api/v2/topology/gate-profiles?region=eu-west&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(euProfiles);
|
||||
Assert.NotEmpty(euProfiles!.Items);
|
||||
Assert.All(euProfiles.Items, item => Assert.Equal("eu-west", item.RegionId));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TopologyEndpoints_WithoutTenantHeader_ReturnBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v2/topology/regions", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TopologyEndpoints_RequireExpectedPolicy()
|
||||
{
|
||||
var endpoints = _factory.Services
|
||||
.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToArray();
|
||||
|
||||
AssertPolicy(endpoints, "/api/v2/topology/regions", "GET", PlatformPolicies.TopologyRead);
|
||||
AssertPolicy(endpoints, "/api/v2/topology/environments", "GET", PlatformPolicies.TopologyRead);
|
||||
AssertPolicy(endpoints, "/api/v2/topology/targets", "GET", PlatformPolicies.TopologyRead);
|
||||
AssertPolicy(endpoints, "/api/v2/topology/hosts", "GET", PlatformPolicies.TopologyRead);
|
||||
AssertPolicy(endpoints, "/api/v2/topology/agents", "GET", PlatformPolicies.TopologyRead);
|
||||
AssertPolicy(endpoints, "/api/v2/topology/promotion-paths", "GET", PlatformPolicies.TopologyRead);
|
||||
AssertPolicy(endpoints, "/api/v2/topology/workflows", "GET", PlatformPolicies.TopologyRead);
|
||||
AssertPolicy(endpoints, "/api/v2/topology/gate-profiles", "GET", PlatformPolicies.TopologyRead);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(
|
||||
IReadOnlyList<RouteEndpoint> endpoints,
|
||||
string routePattern,
|
||||
string method,
|
||||
string expectedPolicy)
|
||||
{
|
||||
static string NormalizePattern(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "/" : value.TrimEnd('/');
|
||||
|
||||
var expectedPattern = NormalizePattern(routePattern);
|
||||
var endpoint = endpoints.Single(candidate =>
|
||||
string.Equals(
|
||||
NormalizePattern(candidate.RoutePattern.RawText ?? string.Empty),
|
||||
expectedPattern,
|
||||
StringComparison.Ordinal)
|
||||
&& candidate.Metadata
|
||||
.GetMetadata<HttpMethodMetadata>()?
|
||||
.HttpMethods
|
||||
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
|
||||
|
||||
var policies = endpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(expectedPolicy, policies);
|
||||
}
|
||||
|
||||
private static async Task SeedReleaseAsync(
|
||||
HttpClient client,
|
||||
string slug,
|
||||
string name,
|
||||
string targetEnvironment,
|
||||
string reason)
|
||||
{
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(bundle);
|
||||
|
||||
var publishResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
|
||||
new PublishReleaseControlBundleVersionRequest(
|
||||
Changelog: "baseline",
|
||||
Components:
|
||||
[
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.0",
|
||||
ComponentName: $"{slug}-api",
|
||||
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
DeployOrder: 10,
|
||||
MetadataJson: "{\"runtime\":\"docker\"}"),
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.1",
|
||||
ComponentName: $"{slug}-worker",
|
||||
ImageDigest: "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
DeployOrder: 20,
|
||||
MetadataJson: "{\"runtime\":\"compose\"}")
|
||||
]),
|
||||
TestContext.Current.CancellationToken);
|
||||
publishResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(version);
|
||||
|
||||
var materializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
|
||||
TestContext.Current.CancellationToken);
|
||||
materializeResponse.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "topology-v2-tests");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user