362 lines
11 KiB
C#
362 lines
11 KiB
C#
// -----------------------------------------------------------------------------
|
|
// AirGapControllerContractTests.cs
|
|
// Sprint: SPRINT_5100_0010_0004_airgap_tests
|
|
// Tasks: AIRGAP-5100-010, AIRGAP-5100-011, AIRGAP-5100-012
|
|
// Description: W1 Controller API contract tests, auth tests, and OTel trace assertions
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Diagnostics;
|
|
using System.Diagnostics.Metrics;
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.AirGap.Controller.Tests;
|
|
|
|
/// <summary>
|
|
/// W1 Controller API Contract Tests
|
|
/// Task AIRGAP-5100-010: Contract tests for AirGap.Controller endpoints (export, import, list bundles)
|
|
/// Task AIRGAP-5100-011: Auth tests (deny-by-default, token expiry, tenant isolation)
|
|
/// Task AIRGAP-5100-012: OTel trace assertions (verify bundle_id, tenant_id, operation tags)
|
|
/// </summary>
|
|
public sealed class AirGapControllerContractTests
|
|
{
|
|
#region AIRGAP-5100-010: Contract Tests
|
|
|
|
[Fact]
|
|
public void Contract_ExportEndpoint_ExpectedRequestStructure()
|
|
{
|
|
// Arrange - Define expected request structure
|
|
var exportRequest = new
|
|
{
|
|
bundleName = "offline-kit-2025",
|
|
version = "1.0.0",
|
|
feeds = new[]
|
|
{
|
|
new { feedId = "nvd", name = "nvd", version = "2025-06-15" }
|
|
},
|
|
policies = new[]
|
|
{
|
|
new { policyId = "default", name = "default", version = "1.0" }
|
|
},
|
|
expiresAt = (DateTimeOffset?)null
|
|
};
|
|
|
|
// Act
|
|
var json = JsonSerializer.Serialize(exportRequest);
|
|
var parsed = JsonDocument.Parse(json);
|
|
|
|
// Assert - Verify structure
|
|
parsed.RootElement.TryGetProperty("bundleName", out _).Should().BeTrue();
|
|
parsed.RootElement.TryGetProperty("version", out _).Should().BeTrue();
|
|
parsed.RootElement.TryGetProperty("feeds", out var feeds).Should().BeTrue();
|
|
feeds.GetArrayLength().Should().BeGreaterThan(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Contract_ExportEndpoint_ExpectedResponseStructure()
|
|
{
|
|
// Arrange - Define expected response structure
|
|
var exportResponse = new
|
|
{
|
|
bundleId = Guid.NewGuid().ToString(),
|
|
bundleDigest = "sha256:" + new string('a', 64),
|
|
downloadUrl = "/api/v1/airgap/bundles/download/{bundleId}",
|
|
expiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
|
manifest = new
|
|
{
|
|
name = "offline-kit-2025",
|
|
version = "1.0.0",
|
|
feedCount = 1,
|
|
policyCount = 1,
|
|
totalSizeBytes = 1024000
|
|
}
|
|
};
|
|
|
|
// Act
|
|
var json = JsonSerializer.Serialize(exportResponse);
|
|
var parsed = JsonDocument.Parse(json);
|
|
|
|
// Assert
|
|
parsed.RootElement.TryGetProperty("bundleId", out _).Should().BeTrue();
|
|
parsed.RootElement.TryGetProperty("bundleDigest", out _).Should().BeTrue();
|
|
parsed.RootElement.TryGetProperty("downloadUrl", out _).Should().BeTrue();
|
|
parsed.RootElement.TryGetProperty("manifest", out _).Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Contract_ImportEndpoint_ExpectedRequestStructure()
|
|
{
|
|
// Arrange - Import request (typically multipart form or bundle URL)
|
|
var importRequest = new
|
|
{
|
|
bundleUrl = "https://storage.example.com/bundles/offline-kit-2025.tar.gz",
|
|
bundleDigest = "sha256:" + new string('b', 64),
|
|
validateOnly = false
|
|
};
|
|
|
|
// Act
|
|
var json = JsonSerializer.Serialize(importRequest);
|
|
var parsed = JsonDocument.Parse(json);
|
|
|
|
// Assert
|
|
parsed.RootElement.TryGetProperty("bundleUrl", out _).Should().BeTrue();
|
|
parsed.RootElement.TryGetProperty("bundleDigest", out _).Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Contract_ImportEndpoint_ExpectedResponseStructure()
|
|
{
|
|
// Arrange
|
|
var importResponse = new
|
|
{
|
|
success = true,
|
|
bundleId = Guid.NewGuid().ToString(),
|
|
importedAt = DateTimeOffset.UtcNow,
|
|
feedsImported = 3,
|
|
policiesImported = 1,
|
|
warnings = Array.Empty<string>()
|
|
};
|
|
|
|
// Act
|
|
var json = JsonSerializer.Serialize(importResponse);
|
|
var parsed = JsonDocument.Parse(json);
|
|
|
|
// Assert
|
|
parsed.RootElement.TryGetProperty("success", out _).Should().BeTrue();
|
|
parsed.RootElement.TryGetProperty("bundleId", out _).Should().BeTrue();
|
|
parsed.RootElement.TryGetProperty("feedsImported", out _).Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Contract_ListBundlesEndpoint_ExpectedResponseStructure()
|
|
{
|
|
// Arrange
|
|
var listResponse = new
|
|
{
|
|
bundles = new[]
|
|
{
|
|
new
|
|
{
|
|
bundleId = Guid.NewGuid().ToString(),
|
|
name = "offline-kit-2025",
|
|
version = "1.0.0",
|
|
createdAt = DateTimeOffset.UtcNow.AddDays(-7),
|
|
expiresAt = DateTimeOffset.UtcNow.AddDays(23),
|
|
bundleDigest = "sha256:" + new string('c', 64),
|
|
totalSizeBytes = 2048000
|
|
}
|
|
},
|
|
total = 1,
|
|
cursor = (string?)null
|
|
};
|
|
|
|
// Act
|
|
var json = JsonSerializer.Serialize(listResponse);
|
|
var parsed = JsonDocument.Parse(json);
|
|
|
|
// Assert
|
|
parsed.RootElement.TryGetProperty("bundles", out var bundles).Should().BeTrue();
|
|
bundles.GetArrayLength().Should().BeGreaterThan(0);
|
|
parsed.RootElement.TryGetProperty("total", out _).Should().BeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void Contract_StateEndpoint_ExpectedResponseStructure()
|
|
{
|
|
// Arrange - AirGap state response
|
|
var stateResponse = new
|
|
{
|
|
tenantId = "tenant-123",
|
|
sealed_ = true,
|
|
policyHash = "sha256:policy123",
|
|
lastTransitionAt = DateTimeOffset.UtcNow,
|
|
stalenessBudget = new { warningSeconds = 1800, breachSeconds = 3600 },
|
|
timeAnchor = new
|
|
{
|
|
timestamp = DateTimeOffset.UtcNow,
|
|
source = "tsa.example.com",
|
|
format = "RFC3161"
|
|
}
|
|
};
|
|
|
|
// Act
|
|
var json = JsonSerializer.Serialize(stateResponse);
|
|
var parsed = JsonDocument.Parse(json);
|
|
|
|
// Assert
|
|
parsed.RootElement.TryGetProperty("tenantId", out _).Should().BeTrue();
|
|
parsed.RootElement.TryGetProperty("sealed_", out _).Should().BeTrue();
|
|
parsed.RootElement.TryGetProperty("stalenessBudget", out _).Should().BeTrue();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region AIRGAP-5100-011: Auth Tests
|
|
|
|
[Fact]
|
|
public void Auth_RequiredScopes_ForExport()
|
|
{
|
|
// Arrange - Expected scopes for export operation
|
|
var requiredScopes = new[] { "airgap:export", "airgap:read" };
|
|
|
|
// Assert - Document expected scope requirements
|
|
requiredScopes.Should().Contain("airgap:export");
|
|
}
|
|
|
|
[Fact]
|
|
public void Auth_RequiredScopes_ForImport()
|
|
{
|
|
// Arrange - Expected scopes for import operation
|
|
var requiredScopes = new[] { "airgap:import", "airgap:write" };
|
|
|
|
// Assert
|
|
requiredScopes.Should().Contain("airgap:import");
|
|
}
|
|
|
|
[Fact]
|
|
public void Auth_RequiredScopes_ForList()
|
|
{
|
|
// Arrange - Expected scopes for list operation
|
|
var requiredScopes = new[] { "airgap:read" };
|
|
|
|
// Assert
|
|
requiredScopes.Should().Contain("airgap:read");
|
|
}
|
|
|
|
[Fact]
|
|
public void Auth_DenyByDefault_NoTokenReturnsUnauthorized()
|
|
{
|
|
// Arrange - Request without token
|
|
var expectedStatusCode = HttpStatusCode.Unauthorized;
|
|
|
|
// Assert - Document expected behavior
|
|
expectedStatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Fact]
|
|
public void Auth_TenantIsolation_CannotAccessOtherTenantBundles()
|
|
{
|
|
// Arrange - Claims for tenant A
|
|
var tenant = "tenant-A";
|
|
var claims = new[]
|
|
{
|
|
new Claim("tenant_id", tenant),
|
|
new Claim("scope", "airgap:read")
|
|
};
|
|
|
|
// Act - Document expected behavior
|
|
var claimsTenant = claims.First(c => c.Type == "tenant_id").Value;
|
|
|
|
// Assert
|
|
claimsTenant.Should().Be(tenant);
|
|
// Requests for tenant-B bundles should be rejected
|
|
}
|
|
|
|
[Fact]
|
|
public void Auth_TokenExpiry_ExpiredTokenReturnsForbidden()
|
|
{
|
|
// Arrange - Expired token scenario
|
|
var tokenExpiry = DateTimeOffset.UtcNow.AddHours(-1);
|
|
var expectedStatusCode = HttpStatusCode.Forbidden;
|
|
|
|
// Assert
|
|
tokenExpiry.Should().BeBefore(DateTimeOffset.UtcNow);
|
|
expectedStatusCode.Should().Be(HttpStatusCode.Forbidden);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region AIRGAP-5100-012: OTel Trace Assertions
|
|
|
|
[Fact]
|
|
public void OTel_ExportOperation_IncludesBundleIdTag()
|
|
{
|
|
// Arrange
|
|
var expectedTags = new[]
|
|
{
|
|
"bundle_id",
|
|
"tenant_id",
|
|
"operation"
|
|
};
|
|
|
|
// Assert - Document expected telemetry tags
|
|
expectedTags.Should().Contain("bundle_id");
|
|
expectedTags.Should().Contain("tenant_id");
|
|
expectedTags.Should().Contain("operation");
|
|
}
|
|
|
|
[Fact]
|
|
public void OTel_ImportOperation_IncludesOperationTag()
|
|
{
|
|
// Arrange
|
|
var operation = "airgap.import";
|
|
var expectedTags = new Dictionary<string, string>
|
|
{
|
|
["operation"] = operation,
|
|
["bundle_digest"] = "sha256:..."
|
|
};
|
|
|
|
// Assert
|
|
expectedTags.Should().ContainKey("operation");
|
|
expectedTags["operation"].Should().Be("airgap.import");
|
|
}
|
|
|
|
[Fact]
|
|
public void OTel_Metrics_TracksExportCount()
|
|
{
|
|
// Arrange
|
|
var meterName = "StellaOps.AirGap.Controller";
|
|
var metricName = "airgap_export_total";
|
|
|
|
// Assert - Document expected metrics
|
|
meterName.Should().NotBeNullOrEmpty();
|
|
metricName.Should().NotBeNullOrEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void OTel_Metrics_TracksImportCount()
|
|
{
|
|
// Arrange
|
|
var metricName = "airgap_import_total";
|
|
var expectedDimensions = new[] { "tenant_id", "status" };
|
|
|
|
// Assert
|
|
metricName.Should().NotBeNullOrEmpty();
|
|
expectedDimensions.Should().Contain("status");
|
|
}
|
|
|
|
[Fact]
|
|
public void OTel_ActivitySource_HasCorrectName()
|
|
{
|
|
// Arrange
|
|
var expectedSourceName = "StellaOps.AirGap.Controller";
|
|
|
|
// Assert
|
|
expectedSourceName.Should().StartWith("StellaOps.");
|
|
}
|
|
|
|
[Fact]
|
|
public void OTel_Spans_PropagateTraceContext()
|
|
{
|
|
// Arrange - Create a trace context
|
|
using var activity = new Activity("test-airgap-operation");
|
|
activity.Start();
|
|
|
|
// Act
|
|
var traceId = activity.TraceId;
|
|
var spanId = activity.SpanId;
|
|
|
|
// Assert
|
|
traceId.Should().NotBe(default(ActivityTraceId));
|
|
spanId.Should().NotBe(default(ActivitySpanId));
|
|
|
|
activity.Stop();
|
|
}
|
|
|
|
#endregion
|
|
}
|