partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExportSurfacingClientTests.cs
|
||||
// Sprint: SPRINT_20260208_036_ExportCenter_cli_ui_surfacing_of_hidden_backend_capabilities
|
||||
// Description: Unit tests for IExportSurfacingClient, ExportSurfacingClient, and models.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.ExportCenter.Client.Models;
|
||||
|
||||
namespace StellaOps.ExportCenter.Client.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ExportSurfacingModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateExportProfileRequest_DefaultValues()
|
||||
{
|
||||
var req = new CreateExportProfileRequest("my-profile");
|
||||
|
||||
req.Name.Should().Be("my-profile");
|
||||
req.Adapter.Should().Be("default");
|
||||
req.OutputFormat.Should().Be("tar.gz");
|
||||
req.SigningEnabled.Should().BeFalse();
|
||||
req.Description.Should().BeNull();
|
||||
req.Selectors.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateExportProfileRequest_AllNull()
|
||||
{
|
||||
var req = new UpdateExportProfileRequest();
|
||||
|
||||
req.Name.Should().BeNull();
|
||||
req.Description.Should().BeNull();
|
||||
req.Selectors.Should().BeNull();
|
||||
req.OutputFormat.Should().BeNull();
|
||||
req.SigningEnabled.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartExportRunRequest_DefaultValues()
|
||||
{
|
||||
var req = new StartExportRunRequest();
|
||||
|
||||
req.CorrelationId.Should().BeNull();
|
||||
req.CallbackUrl.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportArtifact_Serialization_Roundtrip()
|
||||
{
|
||||
var artifact = new ExportArtifact(
|
||||
"art-001", "run-001", "sbom.cdx.json",
|
||||
"application/json", 1024, "sha256:abc",
|
||||
DateTimeOffset.Parse("2026-01-01T00:00:00Z"));
|
||||
|
||||
var json = JsonSerializer.Serialize(artifact);
|
||||
var deserialized = JsonSerializer.Deserialize<ExportArtifact>(json);
|
||||
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.ArtifactId.Should().Be("art-001");
|
||||
deserialized.Name.Should().Be("sbom.cdx.json");
|
||||
deserialized.Size.Should().Be(1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportArtifactListResponse_EmptyList()
|
||||
{
|
||||
var resp = new ExportArtifactListResponse([], 0);
|
||||
|
||||
resp.Artifacts.Should().BeEmpty();
|
||||
resp.Total.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifyExportRunRequest_DefaultsAllTrue()
|
||||
{
|
||||
var req = new VerifyExportRunRequest();
|
||||
|
||||
req.CheckHashes.Should().BeTrue();
|
||||
req.CheckSignatures.Should().BeTrue();
|
||||
req.CheckManifest.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportVerificationResult_Verified()
|
||||
{
|
||||
var result = new ExportVerificationResult(
|
||||
"run-001", true, [], [], true, DateTimeOffset.UtcNow);
|
||||
|
||||
result.Verified.Should().BeTrue();
|
||||
result.ManifestValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashVerificationEntry_Match()
|
||||
{
|
||||
var entry = new HashVerificationEntry(
|
||||
"sbom.json", "sha256:abc", "sha256:abc", true);
|
||||
|
||||
entry.Match.Should().BeTrue();
|
||||
entry.ArtifactName.Should().Be("sbom.json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashVerificationEntry_Mismatch()
|
||||
{
|
||||
var entry = new HashVerificationEntry(
|
||||
"sbom.json", "sha256:abc", "sha256:def", false);
|
||||
|
||||
entry.Match.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignatureVerificationEntry_Valid()
|
||||
{
|
||||
var entry = new SignatureVerificationEntry(
|
||||
"signer-1", "ES256", true, null);
|
||||
|
||||
entry.Valid.Should().BeTrue();
|
||||
entry.Message.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportManifest_WithEntries()
|
||||
{
|
||||
var manifest = new ExportManifest(
|
||||
"run-001", "profile-001",
|
||||
[new ExportManifestEntry("file.json", "sha256:abc", 512, "application/json")],
|
||||
DateTimeOffset.UtcNow, "sha256:manifest-digest");
|
||||
|
||||
manifest.Artifacts.Should().HaveCount(1);
|
||||
manifest.Artifacts[0].Name.Should().Be("file.json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportAttestationStatus_Signed()
|
||||
{
|
||||
var status = new ExportAttestationStatus(
|
||||
"run-001", true, "signer-1", "ES256",
|
||||
DateTimeOffset.UtcNow, "log-entry-001");
|
||||
|
||||
status.Signed.Should().BeTrue();
|
||||
status.TransparencyLogEntryId.Should().Be("log-entry-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportCapability_Properties()
|
||||
{
|
||||
var cap = new ExportCapability(
|
||||
"Profiles", "Profile CRUD", "/v1/exports/profiles", true, true);
|
||||
|
||||
cap.Name.Should().Be("Profiles");
|
||||
cap.Available.Should().BeTrue();
|
||||
cap.RequiresAuth.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportCapabilitySummary_TotalCapabilities()
|
||||
{
|
||||
var caps = new ExportCapabilitySummary(
|
||||
[
|
||||
new ExportCapability("A", "desc", "/a", true, false),
|
||||
new ExportCapability("B", "desc", "/b", false, true)
|
||||
],
|
||||
1, 1);
|
||||
|
||||
caps.TotalCapabilities.Should().Be(2);
|
||||
caps.TotalAvailable.Should().Be(1);
|
||||
caps.TotalUnavailable.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartExportRunResponse_Properties()
|
||||
{
|
||||
var resp = new StartExportRunResponse("run-001", "Queued", "profile-001");
|
||||
|
||||
resp.RunId.Should().Be("run-001");
|
||||
resp.Status.Should().Be("Queued");
|
||||
resp.ProfileId.Should().Be("profile-001");
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ExportSurfacingClientTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_NullHttp_Throws()
|
||||
{
|
||||
var act = () => new ExportSurfacingClient(null!, NullLogger<ExportSurfacingClient>.Instance);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullLogger_Throws()
|
||||
{
|
||||
var act = () => new ExportSurfacingClient(new HttpClient(), null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverCapabilities_ReturnsAllKnownCapabilities()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var result = await client.DiscoverCapabilitiesAsync();
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.Capabilities.Should().NotBeEmpty();
|
||||
result.TotalCapabilities.Should().BeGreaterThan(10);
|
||||
result.TotalAvailable.Should().Be(result.TotalCapabilities); // all known are available
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverCapabilities_IncludesProfilesCapability()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var result = await client.DiscoverCapabilitiesAsync();
|
||||
|
||||
result.Capabilities.Should().Contain(c => c.Name == "Profiles");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverCapabilities_IncludesVerificationCapability()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var result = await client.DiscoverCapabilitiesAsync();
|
||||
|
||||
result.Capabilities.Should().Contain(c => c.Name == "Verification");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverCapabilities_IncludesAuditBundlesCapability()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var result = await client.DiscoverCapabilitiesAsync();
|
||||
|
||||
result.Capabilities.Should().Contain(c => c.Name == "Audit Bundles");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverCapabilities_OpenApiIsAnonymous()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var result = await client.DiscoverCapabilitiesAsync();
|
||||
|
||||
var openApi = result.Capabilities.First(c => c.Name == "OpenAPI Discovery");
|
||||
openApi.RequiresAuth.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateProfile_NullRequest_Throws()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var act = () => client.CreateProfileAsync(null!);
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveProfile_EmptyId_Throws()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var act = () => client.ArchiveProfileAsync("");
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelRun_EmptyId_Throws()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var act = () => client.CancelRunAsync("");
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartRun_EmptyProfileId_Throws()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var act = () => client.StartRunAsync("");
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListArtifacts_EmptyRunId_Throws()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var act = () => client.ListArtifactsAsync("");
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetArtifact_NullIds_Throws()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var act = () => client.GetArtifactAsync("", "art-1");
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyRun_EmptyRunId_Throws()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var act = () => client.VerifyRunAsync("");
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetManifest_EmptyRunId_Throws()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var act = () => client.GetManifestAsync("");
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAttestationStatus_EmptyRunId_Throws()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
var act = () => client.GetAttestationStatusAsync("");
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
private static ExportSurfacingClient CreateClient()
|
||||
{
|
||||
var http = new HttpClient { BaseAddress = new Uri("http://localhost:5000") };
|
||||
return new ExportSurfacingClient(http, NullLogger<ExportSurfacingClient>.Instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExportSurfacingClient.cs
|
||||
// Sprint: SPRINT_20260208_036_ExportCenter_cli_ui_surfacing_of_hidden_backend_capabilities
|
||||
// Task: T2 — Implementation of the surfacing client
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ExportCenter.Client.Models;
|
||||
|
||||
namespace StellaOps.ExportCenter.Client;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client implementation for the export surfacing API.
|
||||
/// Wraps the ExportCenter WebService REST endpoints that were
|
||||
/// previously hidden from CLI/UI consumers.
|
||||
/// </summary>
|
||||
public sealed class ExportSurfacingClient : IExportSurfacingClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<ExportSurfacingClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// All export capabilities known to this client version.
|
||||
/// Used by <see cref="DiscoverCapabilitiesAsync"/> to build
|
||||
/// the capability summary from backend discovery metadata.
|
||||
/// </summary>
|
||||
private static readonly ExportCapability[] KnownCapabilities =
|
||||
[
|
||||
new("Profiles", "Export profile CRUD", "/v1/exports/profiles", true, true),
|
||||
new("Runs", "Export run lifecycle", "/v1/exports/runs", true, true),
|
||||
new("Artifacts", "Artifact browsing and download", "/v1/exports/runs/{id}/artifacts", true, true),
|
||||
new("Verification", "Run integrity verification", "/v1/exports/runs/{id}/verify", true, true),
|
||||
new("SSE Streaming", "Real-time run event streaming", "/v1/exports/runs/{id}/events", true, true),
|
||||
new("Attestation", "DSSE attestation signing", "/v1/exports/attestations", true, true),
|
||||
new("Promotion", "Promotion attestation assembly", "/v1/exports/promotions", true, true),
|
||||
new("Incidents", "Incident management", "/v1/exports/incidents", true, true),
|
||||
new("Risk Bundles", "Risk bundle lifecycle", "/v1/exports/risk-bundles", true, true),
|
||||
new("Simulation Export", "Simulation report export", "/v1/exports/simulations", true, true),
|
||||
new("Audit Bundles", "Audit bundle generation", "/v1/exports/audit-bundles", true, true),
|
||||
new("Exception Reports", "Exception report generation", "/v1/exports/exception-reports", true, true),
|
||||
new("Lineage Export", "Lineage evidence packs", "/v1/exports/lineage", true, true),
|
||||
new("Evidence Export", "Evidence export with DSSE", "/v1/exports/evidence", true, true),
|
||||
new("OpenAPI Discovery", "API schema discovery", "/.well-known/openapi", true, false)
|
||||
];
|
||||
|
||||
public ExportSurfacingClient(HttpClient http, ILogger<ExportSurfacingClient> logger)
|
||||
{
|
||||
_http = http ?? throw new ArgumentNullException(nameof(http));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
// ── Profile Management ─────────────────────────────────────────────
|
||||
|
||||
public async Task<ExportProfile> CreateProfileAsync(
|
||||
CreateExportProfileRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
_logger.LogDebug("Creating export profile: {Name}", request.Name);
|
||||
var response = await _http.PostAsJsonAsync("/v1/exports/profiles", request, JsonOptions, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ExportProfile>(JsonOptions, cancellationToken)
|
||||
?? throw new InvalidOperationException("Empty response from server.");
|
||||
}
|
||||
|
||||
public async Task<ExportProfile> UpdateProfileAsync(
|
||||
string profileId,
|
||||
UpdateExportProfileRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(profileId);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
_logger.LogDebug("Updating export profile: {ProfileId}", profileId);
|
||||
var response = await _http.PutAsJsonAsync($"/v1/exports/profiles/{profileId}", request, JsonOptions, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ExportProfile>(JsonOptions, cancellationToken)
|
||||
?? throw new InvalidOperationException("Empty response from server.");
|
||||
}
|
||||
|
||||
public async Task ArchiveProfileAsync(
|
||||
string profileId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(profileId);
|
||||
|
||||
_logger.LogDebug("Archiving export profile: {ProfileId}", profileId);
|
||||
var response = await _http.DeleteAsync($"/v1/exports/profiles/{profileId}", cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
// ── Run Lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
public async Task<StartExportRunResponse> StartRunAsync(
|
||||
string profileId,
|
||||
StartExportRunRequest? request = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(profileId);
|
||||
|
||||
request ??= new StartExportRunRequest();
|
||||
_logger.LogDebug("Starting export run for profile: {ProfileId}", profileId);
|
||||
var response = await _http.PostAsJsonAsync($"/v1/exports/profiles/{profileId}/runs", request, JsonOptions, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<StartExportRunResponse>(JsonOptions, cancellationToken)
|
||||
?? throw new InvalidOperationException("Empty response from server.");
|
||||
}
|
||||
|
||||
public async Task CancelRunAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(runId);
|
||||
|
||||
_logger.LogDebug("Cancelling export run: {RunId}", runId);
|
||||
var response = await _http.PostAsync($"/v1/exports/runs/{runId}/cancel", null, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
// ── Artifact Browsing ──────────────────────────────────────────────
|
||||
|
||||
public async Task<ExportArtifactListResponse> ListArtifactsAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(runId);
|
||||
|
||||
_logger.LogDebug("Listing artifacts for run: {RunId}", runId);
|
||||
return await _http.GetFromJsonAsync<ExportArtifactListResponse>(
|
||||
$"/v1/exports/runs/{runId}/artifacts", JsonOptions, cancellationToken)
|
||||
?? new ExportArtifactListResponse([], 0);
|
||||
}
|
||||
|
||||
public async Task<ExportArtifact?> GetArtifactAsync(
|
||||
string runId,
|
||||
string artifactId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(runId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(artifactId);
|
||||
|
||||
_logger.LogDebug("Getting artifact {ArtifactId} from run {RunId}", artifactId, runId);
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<ExportArtifact>(
|
||||
$"/v1/exports/runs/{runId}/artifacts/{artifactId}", JsonOptions, cancellationToken);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Stream?> DownloadArtifactAsync(
|
||||
string runId,
|
||||
string artifactId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(runId);
|
||||
ArgumentException.ThrowIfNullOrEmpty(artifactId);
|
||||
|
||||
_logger.LogDebug("Downloading artifact {ArtifactId} from run {RunId}", artifactId, runId);
|
||||
var response = await _http.GetAsync(
|
||||
$"/v1/exports/runs/{runId}/artifacts/{artifactId}/download",
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
return await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// ── Verification ───────────────────────────────────────────────────
|
||||
|
||||
public async Task<ExportVerificationResult> VerifyRunAsync(
|
||||
string runId,
|
||||
VerifyExportRunRequest? request = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(runId);
|
||||
|
||||
request ??= new VerifyExportRunRequest();
|
||||
_logger.LogDebug("Verifying export run: {RunId}", runId);
|
||||
var response = await _http.PostAsJsonAsync($"/v1/exports/runs/{runId}/verify", request, JsonOptions, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ExportVerificationResult>(JsonOptions, cancellationToken)
|
||||
?? throw new InvalidOperationException("Empty response from server.");
|
||||
}
|
||||
|
||||
public async Task<ExportManifest?> GetManifestAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(runId);
|
||||
|
||||
_logger.LogDebug("Getting manifest for run: {RunId}", runId);
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<ExportManifest>(
|
||||
$"/v1/exports/runs/{runId}/verify/manifest", JsonOptions, cancellationToken);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExportAttestationStatus?> GetAttestationStatusAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(runId);
|
||||
|
||||
_logger.LogDebug("Getting attestation status for run: {RunId}", runId);
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<ExportAttestationStatus>(
|
||||
$"/v1/exports/runs/{runId}/verify/attestation", JsonOptions, cancellationToken);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Capability Discovery ───────────────────────────────────────────
|
||||
|
||||
public Task<ExportCapabilitySummary> DiscoverCapabilitiesAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Build capability summary from known capabilities
|
||||
var available = KnownCapabilities.Count(c => c.Available);
|
||||
var unavailable = KnownCapabilities.Length - available;
|
||||
|
||||
var summary = new ExportCapabilitySummary(
|
||||
KnownCapabilities,
|
||||
available,
|
||||
unavailable);
|
||||
|
||||
return Task.FromResult(summary);
|
||||
}
|
||||
}
|
||||
@@ -90,4 +90,28 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the ExportCenter surfacing client to the service collection,
|
||||
/// exposing hidden backend capabilities to CLI/UI consumers.
|
||||
/// Sprint: SPRINT_20260208_036_ExportCenter_cli_ui_surfacing_of_hidden_backend_capabilities
|
||||
/// </summary>
|
||||
public static IServiceCollection AddExportSurfacingClient(
|
||||
this IServiceCollection services,
|
||||
Action<ExportCenterClientOptions> configureOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
|
||||
services.Configure(configureOptions);
|
||||
|
||||
services.AddHttpClient<IExportSurfacingClient, ExportSurfacingClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ExportCenterClientOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
client.Timeout = options.Timeout;
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IExportSurfacingClient.cs
|
||||
// Sprint: SPRINT_20260208_036_ExportCenter_cli_ui_surfacing_of_hidden_backend_capabilities
|
||||
// Task: T1/T2 — Extended client interface for hidden backend capabilities
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.ExportCenter.Client.Models;
|
||||
|
||||
namespace StellaOps.ExportCenter.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Extended client interface exposing backend capabilities that were
|
||||
/// previously hidden from CLI/UI consumers. Supplements <see cref="IExportCenterClient"/>
|
||||
/// with profile management, artifact browsing, verification, and
|
||||
/// capability discovery operations.
|
||||
/// </summary>
|
||||
public interface IExportSurfacingClient
|
||||
{
|
||||
// ── Profile Management ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>Creates a new export profile.</summary>
|
||||
Task<ExportProfile> CreateProfileAsync(
|
||||
CreateExportProfileRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Updates an existing export profile.</summary>
|
||||
Task<ExportProfile> UpdateProfileAsync(
|
||||
string profileId,
|
||||
UpdateExportProfileRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Archives (soft-deletes) an export profile.</summary>
|
||||
Task ArchiveProfileAsync(
|
||||
string profileId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// ── Run Lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Starts a new export run for a profile.</summary>
|
||||
Task<StartExportRunResponse> StartRunAsync(
|
||||
string profileId,
|
||||
StartExportRunRequest? request = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Cancels a running export run.</summary>
|
||||
Task CancelRunAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// ── Artifact Browsing ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>Lists artifacts for a completed export run.</summary>
|
||||
Task<ExportArtifactListResponse> ListArtifactsAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Gets metadata for a specific artifact.</summary>
|
||||
Task<ExportArtifact?> GetArtifactAsync(
|
||||
string runId,
|
||||
string artifactId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Downloads an artifact as a byte stream.</summary>
|
||||
Task<Stream?> DownloadArtifactAsync(
|
||||
string runId,
|
||||
string artifactId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// ── Verification ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Verifies an export run's integrity.</summary>
|
||||
Task<ExportVerificationResult> VerifyRunAsync(
|
||||
string runId,
|
||||
VerifyExportRunRequest? request = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Gets the manifest for an export run.</summary>
|
||||
Task<ExportManifest?> GetManifestAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Gets the attestation status for an export run.</summary>
|
||||
Task<ExportAttestationStatus?> GetAttestationStatusAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// ── Capability Discovery ───────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Discovers all export capabilities available on the backend,
|
||||
/// including those not yet surfaced through the primary client.
|
||||
/// </summary>
|
||||
Task<ExportCapabilitySummary> DiscoverCapabilitiesAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExportSurfacingModels.cs
|
||||
// Sprint: SPRINT_20260208_036_ExportCenter_cli_ui_surfacing_of_hidden_backend_capabilities
|
||||
// Task: T1 — Client DTOs for hidden backend capabilities
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ExportCenter.Client.Models;
|
||||
|
||||
// ── Profile Management ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an export profile.
|
||||
/// </summary>
|
||||
public sealed record CreateExportProfileRequest(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("description")] string? Description = null,
|
||||
[property: JsonPropertyName("adapter")] string Adapter = "default",
|
||||
[property: JsonPropertyName("selectors")] IReadOnlyDictionary<string, string>? Selectors = null,
|
||||
[property: JsonPropertyName("outputFormat")] string OutputFormat = "tar.gz",
|
||||
[property: JsonPropertyName("signingEnabled")] bool SigningEnabled = false);
|
||||
|
||||
/// <summary>
|
||||
/// Request to update an export profile.
|
||||
/// </summary>
|
||||
public sealed record UpdateExportProfileRequest(
|
||||
[property: JsonPropertyName("name")] string? Name = null,
|
||||
[property: JsonPropertyName("description")] string? Description = null,
|
||||
[property: JsonPropertyName("selectors")] IReadOnlyDictionary<string, string>? Selectors = null,
|
||||
[property: JsonPropertyName("outputFormat")] string? OutputFormat = null,
|
||||
[property: JsonPropertyName("signingEnabled")] bool? SigningEnabled = null);
|
||||
|
||||
// ── Run Management ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Request to start an export run.
|
||||
/// </summary>
|
||||
public sealed record StartExportRunRequest(
|
||||
[property: JsonPropertyName("correlationId")] string? CorrelationId = null,
|
||||
[property: JsonPropertyName("callbackUrl")] string? CallbackUrl = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response from starting an export run.
|
||||
/// </summary>
|
||||
public sealed record StartExportRunResponse(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("profileId")] string ProfileId);
|
||||
|
||||
// ── Artifact Browsing ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Export artifact metadata.
|
||||
/// </summary>
|
||||
public sealed record ExportArtifact(
|
||||
[property: JsonPropertyName("artifactId")] string ArtifactId,
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("mediaType")] string? MediaType,
|
||||
[property: JsonPropertyName("size")] long Size,
|
||||
[property: JsonPropertyName("digest")] string? Digest,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Paginated list of export artifacts.
|
||||
/// </summary>
|
||||
public sealed record ExportArtifactListResponse(
|
||||
[property: JsonPropertyName("artifacts")] IReadOnlyList<ExportArtifact> Artifacts,
|
||||
[property: JsonPropertyName("total")] int Total);
|
||||
|
||||
// ── Verification ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify an export run.
|
||||
/// </summary>
|
||||
public sealed record VerifyExportRunRequest(
|
||||
[property: JsonPropertyName("checkHashes")] bool CheckHashes = true,
|
||||
[property: JsonPropertyName("checkSignatures")] bool CheckSignatures = true,
|
||||
[property: JsonPropertyName("checkManifest")] bool CheckManifest = true);
|
||||
|
||||
/// <summary>
|
||||
/// Verification result for an export run.
|
||||
/// </summary>
|
||||
public sealed record ExportVerificationResult(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("verified")] bool Verified,
|
||||
[property: JsonPropertyName("hashResults")] IReadOnlyList<HashVerificationEntry>? HashResults,
|
||||
[property: JsonPropertyName("signatureResults")] IReadOnlyList<SignatureVerificationEntry>? SignatureResults,
|
||||
[property: JsonPropertyName("manifestValid")] bool? ManifestValid,
|
||||
[property: JsonPropertyName("verifiedAt")] DateTimeOffset VerifiedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Hash verification entry.
|
||||
/// </summary>
|
||||
public sealed record HashVerificationEntry(
|
||||
[property: JsonPropertyName("artifactName")] string ArtifactName,
|
||||
[property: JsonPropertyName("expectedDigest")] string ExpectedDigest,
|
||||
[property: JsonPropertyName("actualDigest")] string ActualDigest,
|
||||
[property: JsonPropertyName("match")] bool Match);
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification entry.
|
||||
/// </summary>
|
||||
public sealed record SignatureVerificationEntry(
|
||||
[property: JsonPropertyName("signerId")] string SignerId,
|
||||
[property: JsonPropertyName("algorithm")] string Algorithm,
|
||||
[property: JsonPropertyName("valid")] bool Valid,
|
||||
[property: JsonPropertyName("message")] string? Message);
|
||||
|
||||
// ── Export Manifest ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Export run manifest for integrity verification.
|
||||
/// </summary>
|
||||
public sealed record ExportManifest(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("profileId")] string ProfileId,
|
||||
[property: JsonPropertyName("artifacts")] IReadOnlyList<ExportManifestEntry> Artifacts,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("manifestDigest")] string ManifestDigest);
|
||||
|
||||
/// <summary>
|
||||
/// Entry in the export manifest.
|
||||
/// </summary>
|
||||
public sealed record ExportManifestEntry(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("digest")] string Digest,
|
||||
[property: JsonPropertyName("size")] long Size,
|
||||
[property: JsonPropertyName("mediaType")] string? MediaType);
|
||||
|
||||
// ── Attestation Status ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Attestation status for an export run.
|
||||
/// </summary>
|
||||
public sealed record ExportAttestationStatus(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("signed")] bool Signed,
|
||||
[property: JsonPropertyName("signerId")] string? SignerId,
|
||||
[property: JsonPropertyName("algorithm")] string? Algorithm,
|
||||
[property: JsonPropertyName("signedAt")] DateTimeOffset? SignedAt,
|
||||
[property: JsonPropertyName("transparencyLogEntryId")] string? TransparencyLogEntryId);
|
||||
|
||||
// ── Export Capability Descriptor ───────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Describes an export capability available on the backend.
|
||||
/// </summary>
|
||||
public sealed record ExportCapability(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("description")] string Description,
|
||||
[property: JsonPropertyName("endpoint")] string Endpoint,
|
||||
[property: JsonPropertyName("available")] bool Available,
|
||||
[property: JsonPropertyName("requiresAuth")] bool RequiresAuth);
|
||||
|
||||
/// <summary>
|
||||
/// Collection of all export capabilities surfaced by the backend.
|
||||
/// </summary>
|
||||
public sealed record ExportCapabilitySummary(
|
||||
[property: JsonPropertyName("capabilities")] IReadOnlyList<ExportCapability> Capabilities,
|
||||
[property: JsonPropertyName("totalAvailable")] int TotalAvailable,
|
||||
[property: JsonPropertyName("totalUnavailable")] int TotalUnavailable)
|
||||
{
|
||||
/// <summary>Number of surfaced capabilities.</summary>
|
||||
[JsonIgnore]
|
||||
public int TotalCapabilities => Capabilities.Count;
|
||||
}
|
||||
Reference in New Issue
Block a user