- Implement ProofChainTestFixture for PostgreSQL-backed integration tests. - Create StellaOps.Integration.ProofChain project with necessary dependencies. - Add ReachabilityIntegrationTests to validate call graph extraction and reachability analysis. - Introduce ReachabilityTestFixture for managing corpus and fixture paths. - Establish StellaOps.Integration.Reachability project with required references. - Develop UnknownsWorkflowTests to cover the unknowns lifecycle: detection, ranking, escalation, and resolution. - Create StellaOps.Integration.Unknowns project with dependencies for unknowns workflow.
385 lines
12 KiB
C#
385 lines
12 KiB
C#
// =============================================================================
|
|
// StellaOps.Integration.AirGap - Air-Gap Integration Tests
|
|
// Sprint 3500.0004.0003 - T8: Air-Gap Integration Tests
|
|
// =============================================================================
|
|
|
|
using FluentAssertions;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Integration.AirGap;
|
|
|
|
/// <summary>
|
|
/// Integration tests for air-gapped (offline) operation.
|
|
/// Validates that StellaOps functions correctly without network access.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// T8-AC1: Offline kit installation test
|
|
/// T8-AC2: Offline scan test
|
|
/// T8-AC3: Offline score replay test
|
|
/// T8-AC4: Offline proof verification test
|
|
/// T8-AC5: No network calls during offline operation
|
|
/// </remarks>
|
|
[Trait("Category", "AirGap")]
|
|
[Trait("Category", "Integration")]
|
|
[Trait("Category", "Offline")]
|
|
public class AirGapIntegrationTests : IClassFixture<AirGapTestFixture>
|
|
{
|
|
private readonly AirGapTestFixture _fixture;
|
|
|
|
public AirGapIntegrationTests(AirGapTestFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
#region T8-AC1: Offline Kit Installation
|
|
|
|
[Fact(DisplayName = "T8-AC1.1: Offline kit manifest is valid")]
|
|
public void OfflineKitManifest_IsValid()
|
|
{
|
|
// Arrange & Act
|
|
var manifest = _fixture.GetOfflineKitManifest();
|
|
|
|
// Assert
|
|
manifest.Should().NotBeNull();
|
|
manifest.Version.Should().NotBeNullOrEmpty();
|
|
manifest.Components.Should().NotBeEmpty();
|
|
manifest.CreatedAt.Should().BeBefore(DateTime.UtcNow);
|
|
}
|
|
|
|
[Fact(DisplayName = "T8-AC1.2: All required components present")]
|
|
public void OfflineKit_HasRequiredComponents()
|
|
{
|
|
// Arrange
|
|
var requiredComponents = new[]
|
|
{
|
|
"vulnerability-database",
|
|
"advisory-feeds",
|
|
"trust-bundles",
|
|
"signing-keys"
|
|
};
|
|
|
|
// Act
|
|
var manifest = _fixture.GetOfflineKitManifest();
|
|
|
|
// Assert
|
|
foreach (var component in requiredComponents)
|
|
{
|
|
manifest.Components.Should().ContainKey(component,
|
|
$"Offline kit missing required component: {component}");
|
|
}
|
|
}
|
|
|
|
[Fact(DisplayName = "T8-AC1.3: Component hashes are valid")]
|
|
public async Task OfflineKitComponents_HaveValidHashes()
|
|
{
|
|
// Arrange
|
|
var manifest = _fixture.GetOfflineKitManifest();
|
|
var invalidComponents = new List<string>();
|
|
|
|
// Act
|
|
foreach (var (name, component) in manifest.Components)
|
|
{
|
|
var actualHash = await _fixture.ComputeComponentHashAsync(name);
|
|
if (actualHash != component.Hash)
|
|
{
|
|
invalidComponents.Add($"{name}: expected {component.Hash}, got {actualHash}");
|
|
}
|
|
}
|
|
|
|
// Assert
|
|
invalidComponents.Should().BeEmpty(
|
|
$"Components with invalid hashes:\n{string.Join("\n", invalidComponents)}");
|
|
}
|
|
|
|
[Fact(DisplayName = "T8-AC1.4: Offline kit installation succeeds")]
|
|
public async Task OfflineKitInstallation_Succeeds()
|
|
{
|
|
// Arrange
|
|
var targetPath = _fixture.GetTempDirectory();
|
|
|
|
// Act
|
|
var result = await _fixture.InstallOfflineKitAsync(targetPath);
|
|
|
|
// Assert
|
|
result.Success.Should().BeTrue();
|
|
result.InstalledComponents.Should().NotBeEmpty();
|
|
Directory.Exists(targetPath).Should().BeTrue();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region T8-AC2: Offline Scan
|
|
|
|
[Fact(DisplayName = "T8-AC2.1: Scan completes without network")]
|
|
public async Task OfflineScan_CompletesWithoutNetwork()
|
|
{
|
|
// Arrange
|
|
await _fixture.DisableNetworkAsync();
|
|
var targetImage = _fixture.GetLocalTestImage();
|
|
|
|
try
|
|
{
|
|
// Act
|
|
var result = await _fixture.RunOfflineScanAsync(targetImage);
|
|
|
|
// Assert
|
|
result.Success.Should().BeTrue();
|
|
result.Findings.Should().NotBeNull();
|
|
}
|
|
finally
|
|
{
|
|
await _fixture.EnableNetworkAsync();
|
|
}
|
|
}
|
|
|
|
[Fact(DisplayName = "T8-AC2.2: Scan uses local vulnerability database")]
|
|
public async Task OfflineScan_UsesLocalVulnDatabase()
|
|
{
|
|
// Arrange
|
|
var targetImage = _fixture.GetLocalTestImage();
|
|
_fixture.SetOfflineMode(true);
|
|
|
|
// Act
|
|
var result = await _fixture.RunOfflineScanAsync(targetImage);
|
|
|
|
// Assert
|
|
result.Success.Should().BeTrue();
|
|
result.DataSource.Should().Be("offline-kit");
|
|
result.DataSourcePath.Should().Contain("offline");
|
|
}
|
|
|
|
[Fact(DisplayName = "T8-AC2.3: Scan produces deterministic results offline")]
|
|
public async Task OfflineScan_ProducesDeterministicResults()
|
|
{
|
|
// Arrange
|
|
var targetImage = _fixture.GetLocalTestImage();
|
|
_fixture.SetOfflineMode(true);
|
|
|
|
// Act - run twice
|
|
var result1 = await _fixture.RunOfflineScanAsync(targetImage);
|
|
var result2 = await _fixture.RunOfflineScanAsync(targetImage);
|
|
|
|
// Assert
|
|
result1.ManifestHash.Should().Be(result2.ManifestHash,
|
|
"Offline scan should produce identical results");
|
|
result1.Findings.Count.Should().Be(result2.Findings.Count);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region T8-AC3: Offline Score Replay
|
|
|
|
[Fact(DisplayName = "T8-AC3.1: Score replay works offline")]
|
|
public async Task ScoreReplay_WorksOffline()
|
|
{
|
|
// Arrange
|
|
var proofBundle = _fixture.GetSampleProofBundle();
|
|
_fixture.SetOfflineMode(true);
|
|
|
|
// Act
|
|
var result = await _fixture.ReplayScoreOfflineAsync(proofBundle);
|
|
|
|
// Assert
|
|
result.Success.Should().BeTrue();
|
|
result.Score.Should().BeGreaterThanOrEqualTo(0);
|
|
result.ReplayedAt.Should().BeBefore(DateTime.UtcNow);
|
|
}
|
|
|
|
[Fact(DisplayName = "T8-AC3.2: Score replay produces identical score")]
|
|
public async Task ScoreReplay_ProducesIdenticalScore()
|
|
{
|
|
// Arrange
|
|
var proofBundle = _fixture.GetSampleProofBundle();
|
|
var originalScore = proofBundle.OriginalScore;
|
|
_fixture.SetOfflineMode(true);
|
|
|
|
// Act
|
|
var result = await _fixture.ReplayScoreOfflineAsync(proofBundle);
|
|
|
|
// Assert
|
|
result.Score.Should().Be(originalScore,
|
|
"Replay score should match original");
|
|
result.ScoreHash.Should().Be(proofBundle.OriginalScoreHash,
|
|
"Replay score hash should match original");
|
|
}
|
|
|
|
[Fact(DisplayName = "T8-AC3.3: Score replay includes audit trail")]
|
|
public async Task ScoreReplay_IncludesAuditTrail()
|
|
{
|
|
// Arrange
|
|
var proofBundle = _fixture.GetSampleProofBundle();
|
|
_fixture.SetOfflineMode(true);
|
|
|
|
// Act
|
|
var result = await _fixture.ReplayScoreOfflineAsync(proofBundle);
|
|
|
|
// Assert
|
|
result.AuditTrail.Should().NotBeEmpty();
|
|
result.AuditTrail.Should().Contain(a => a.Type == "replay_started");
|
|
result.AuditTrail.Should().Contain(a => a.Type == "replay_completed");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region T8-AC4: Offline Proof Verification
|
|
|
|
[Fact(DisplayName = "T8-AC4.1: Proof verification works offline")]
|
|
public async Task ProofVerification_WorksOffline()
|
|
{
|
|
// Arrange
|
|
var proofBundle = _fixture.GetSampleProofBundle();
|
|
_fixture.SetOfflineMode(true);
|
|
|
|
// Act
|
|
var result = await _fixture.VerifyProofOfflineAsync(proofBundle);
|
|
|
|
// Assert
|
|
result.Valid.Should().BeTrue();
|
|
result.VerifiedAt.Should().BeBefore(DateTime.UtcNow);
|
|
}
|
|
|
|
[Fact(DisplayName = "T8-AC4.2: Verification uses offline trust store")]
|
|
public async Task ProofVerification_UsesOfflineTrustStore()
|
|
{
|
|
// Arrange
|
|
var proofBundle = _fixture.GetSampleProofBundle();
|
|
_fixture.SetOfflineMode(true);
|
|
|
|
// Act
|
|
var result = await _fixture.VerifyProofOfflineAsync(proofBundle);
|
|
|
|
// Assert
|
|
result.TrustSource.Should().Be("offline-trust-store");
|
|
result.CertificateChain.Should().NotBeEmpty();
|
|
}
|
|
|
|
[Fact(DisplayName = "T8-AC4.3: Tampered proof fails verification")]
|
|
public async Task TamperedProof_FailsVerification()
|
|
{
|
|
// Arrange
|
|
var proofBundle = _fixture.GetSampleProofBundle();
|
|
var tamperedBundle = _fixture.TamperWithProof(proofBundle);
|
|
_fixture.SetOfflineMode(true);
|
|
|
|
// Act
|
|
var result = await _fixture.VerifyProofOfflineAsync(tamperedBundle);
|
|
|
|
// Assert
|
|
result.Valid.Should().BeFalse();
|
|
result.FailureReason.Should().Contain("signature");
|
|
}
|
|
|
|
[Fact(DisplayName = "T8-AC4.4: Expired certificate handling offline")]
|
|
public async Task ExpiredCertificate_HandledCorrectly()
|
|
{
|
|
// Arrange
|
|
var proofBundle = _fixture.GetProofBundleWithExpiredCert();
|
|
_fixture.SetOfflineMode(true);
|
|
|
|
// Act
|
|
var result = await _fixture.VerifyProofOfflineAsync(proofBundle);
|
|
|
|
// Assert
|
|
result.Valid.Should().BeFalse();
|
|
result.FailureReason.Should().Contain("expired");
|
|
result.Warnings.Should().ContainSingle(w => w.Contains("certificate"));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region T8-AC5: No Network Calls
|
|
|
|
[Fact(DisplayName = "T8-AC5.1: No outbound connections during scan")]
|
|
public async Task OfflineScan_NoOutboundConnections()
|
|
{
|
|
// Arrange
|
|
var connectionAttempts = new List<string>();
|
|
_fixture.SetConnectionMonitor(endpoint => connectionAttempts.Add(endpoint));
|
|
_fixture.SetOfflineMode(true);
|
|
var targetImage = _fixture.GetLocalTestImage();
|
|
|
|
// Act
|
|
await _fixture.RunOfflineScanAsync(targetImage);
|
|
|
|
// Assert
|
|
connectionAttempts.Should().BeEmpty(
|
|
$"Unexpected network connections:\n{string.Join("\n", connectionAttempts)}");
|
|
}
|
|
|
|
[Fact(DisplayName = "T8-AC5.2: No outbound connections during verification")]
|
|
public async Task OfflineVerification_NoOutboundConnections()
|
|
{
|
|
// Arrange
|
|
var connectionAttempts = new List<string>();
|
|
_fixture.SetConnectionMonitor(endpoint => connectionAttempts.Add(endpoint));
|
|
_fixture.SetOfflineMode(true);
|
|
var proofBundle = _fixture.GetSampleProofBundle();
|
|
|
|
// Act
|
|
await _fixture.VerifyProofOfflineAsync(proofBundle);
|
|
|
|
// Assert
|
|
connectionAttempts.Should().BeEmpty(
|
|
$"Unexpected network connections:\n{string.Join("\n", connectionAttempts)}");
|
|
}
|
|
|
|
[Fact(DisplayName = "T8-AC5.3: No DNS lookups in offline mode")]
|
|
public async Task OfflineMode_NoDnsLookups()
|
|
{
|
|
// Arrange
|
|
var dnsLookups = new List<string>();
|
|
_fixture.SetDnsMonitor(hostname => dnsLookups.Add(hostname));
|
|
_fixture.SetOfflineMode(true);
|
|
|
|
// Act
|
|
var targetImage = _fixture.GetLocalTestImage();
|
|
await _fixture.RunOfflineScanAsync(targetImage);
|
|
|
|
// Assert
|
|
dnsLookups.Should().BeEmpty(
|
|
$"Unexpected DNS lookups:\n{string.Join("\n", dnsLookups)}");
|
|
}
|
|
|
|
[Fact(DisplayName = "T8-AC5.4: Telemetry disabled in offline mode")]
|
|
public async Task OfflineMode_TelemetryDisabled()
|
|
{
|
|
// Arrange
|
|
_fixture.SetOfflineMode(true);
|
|
var targetImage = _fixture.GetLocalTestImage();
|
|
|
|
// Act
|
|
var result = await _fixture.RunOfflineScanAsync(targetImage);
|
|
|
|
// Assert
|
|
result.TelemetrySent.Should().BeFalse();
|
|
result.Configuration.TelemetryEnabled.Should().BeFalse();
|
|
}
|
|
|
|
[Fact(DisplayName = "T8-AC5.5: Network operations gracefully fail")]
|
|
public async Task NetworkOperations_GracefullyFail()
|
|
{
|
|
// Arrange
|
|
await _fixture.DisableNetworkAsync();
|
|
|
|
try
|
|
{
|
|
// Act - attempt online operation
|
|
var result = await _fixture.AttemptOnlineUpdateAsync();
|
|
|
|
// Assert
|
|
result.Success.Should().BeFalse();
|
|
result.FailureReason.Should().Contain("offline");
|
|
result.SuggestedAction.Should().Contain("offline-kit");
|
|
}
|
|
finally
|
|
{
|
|
await _fixture.EnableNetworkAsync();
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|