// ============================================================================= // 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; /// /// Integration tests for air-gapped (offline) operation. /// Validates that StellaOps functions correctly without network access. /// /// /// 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 /// [Trait("Category", "AirGap")] [Trait("Category", "Integration")] [Trait("Category", "Offline")] public class AirGapIntegrationTests : IClassFixture { 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(); // 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(); _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(); _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(); _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 }