Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
164 lines
6.2 KiB
C#
164 lines
6.2 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.AirGap.Controller.Domain;
|
|
using StellaOps.AirGap.Controller.Options;
|
|
using StellaOps.AirGap.Controller.Services;
|
|
using StellaOps.AirGap.Controller.Stores;
|
|
using StellaOps.AirGap.Importer.Validation;
|
|
using StellaOps.AirGap.Time.Models;
|
|
using StellaOps.AirGap.Time.Services;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.AirGap.Controller.Tests;
|
|
|
|
public class AirGapStartupDiagnosticsHostedServiceTests
|
|
{
|
|
[Fact]
|
|
public async Task Blocks_when_allowlist_missing_for_sealed_state()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var store = new InMemoryAirGapStateStore();
|
|
await store.SetAsync(new AirGapState
|
|
{
|
|
TenantId = "default",
|
|
Sealed = true,
|
|
PolicyHash = "policy-x",
|
|
TimeAnchor = new TimeAnchor(now, "rough", "rough", "fp", "digest"),
|
|
StalenessBudget = new StalenessBudget(60, 120)
|
|
});
|
|
|
|
var trustDir = CreateTrustMaterial();
|
|
var options = BuildOptions(trustDir);
|
|
options.EgressAllowlist = null; // simulate missing config section
|
|
|
|
var service = CreateService(store, options, now);
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.StartAsync(CancellationToken.None));
|
|
Assert.Contains("egress-allowlist-missing", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Passes_when_materials_present_and_anchor_fresh()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var store = new InMemoryAirGapStateStore();
|
|
await store.SetAsync(new AirGapState
|
|
{
|
|
TenantId = "default",
|
|
Sealed = true,
|
|
PolicyHash = "policy-ok",
|
|
TimeAnchor = new TimeAnchor(now.AddMinutes(-1), "rough", "rough", "fp", "digest"),
|
|
StalenessBudget = new StalenessBudget(300, 600)
|
|
});
|
|
|
|
var trustDir = CreateTrustMaterial();
|
|
var options = BuildOptions(trustDir, new[] { "127.0.0.1/32" });
|
|
|
|
var service = CreateService(store, options, now);
|
|
|
|
await service.StartAsync(CancellationToken.None); // should not throw
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Blocks_when_anchor_is_stale()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var store = new InMemoryAirGapStateStore();
|
|
await store.SetAsync(new AirGapState
|
|
{
|
|
TenantId = "default",
|
|
Sealed = true,
|
|
PolicyHash = "policy-stale",
|
|
TimeAnchor = new TimeAnchor(now.AddHours(-2), "rough", "rough", "fp", "digest"),
|
|
StalenessBudget = new StalenessBudget(60, 90)
|
|
});
|
|
|
|
var trustDir = CreateTrustMaterial();
|
|
var options = BuildOptions(trustDir, new[] { "10.0.0.0/24" });
|
|
|
|
var service = CreateService(store, options, now);
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.StartAsync(CancellationToken.None));
|
|
Assert.Contains("time-anchor-stale", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Blocks_when_rotation_pending_without_dual_approval()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var store = new InMemoryAirGapStateStore();
|
|
await store.SetAsync(new AirGapState
|
|
{
|
|
TenantId = "default",
|
|
Sealed = true,
|
|
PolicyHash = "policy-rot",
|
|
TimeAnchor = new TimeAnchor(now, "rough", "rough", "fp", "digest"),
|
|
StalenessBudget = new StalenessBudget(120, 240)
|
|
});
|
|
|
|
var trustDir = CreateTrustMaterial();
|
|
var options = BuildOptions(trustDir, new[] { "10.10.0.0/16" });
|
|
options.Rotation.PendingKeys["k-new"] = Convert.ToBase64String(new byte[] { 1, 2, 3 });
|
|
options.Rotation.ActiveKeys["k-old"] = Convert.ToBase64String(new byte[] { 9, 9, 9 });
|
|
options.Rotation.ApproverIds.Add("approver-1");
|
|
|
|
var service = CreateService(store, options, now);
|
|
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.StartAsync(CancellationToken.None));
|
|
Assert.Contains("rotation:rotation-dual-approval-required", ex.Message);
|
|
}
|
|
|
|
private static AirGapStartupOptions BuildOptions(string trustDir, string[]? allowlist = null)
|
|
{
|
|
return new AirGapStartupOptions
|
|
{
|
|
TenantId = "default",
|
|
EgressAllowlist = allowlist,
|
|
Trust = new TrustMaterialOptions
|
|
{
|
|
RootJsonPath = Path.Combine(trustDir, "root.json"),
|
|
SnapshotJsonPath = Path.Combine(trustDir, "snapshot.json"),
|
|
TimestampJsonPath = Path.Combine(trustDir, "timestamp.json")
|
|
}
|
|
};
|
|
}
|
|
|
|
private static AirGapStartupDiagnosticsHostedService CreateService(IAirGapStateStore store, AirGapStartupOptions options, DateTimeOffset now)
|
|
{
|
|
return new AirGapStartupDiagnosticsHostedService(
|
|
store,
|
|
new StalenessCalculator(),
|
|
new FixedTimeProvider(now),
|
|
Microsoft.Extensions.Options.Options.Create(options),
|
|
NullLogger<AirGapStartupDiagnosticsHostedService>.Instance,
|
|
new AirGapTelemetry(NullLogger<AirGapTelemetry>.Instance),
|
|
new TufMetadataValidator(),
|
|
new RootRotationPolicy());
|
|
}
|
|
|
|
private static string CreateTrustMaterial()
|
|
{
|
|
var dir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "airgap-trust-" + Guid.NewGuid().ToString("N"))).FullName;
|
|
var expires = DateTimeOffset.UtcNow.AddDays(1).ToString("O");
|
|
const string hash = "abc123";
|
|
|
|
File.WriteAllText(Path.Combine(dir, "root.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\"}}");
|
|
File.WriteAllText(Path.Combine(dir, "snapshot.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"meta\":{{\"snapshot\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
|
|
File.WriteAllText(Path.Combine(dir, "timestamp.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"snapshot\":{{\"meta\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
|
|
|
|
return dir;
|
|
}
|
|
|
|
private sealed class FixedTimeProvider : TimeProvider
|
|
{
|
|
private readonly DateTimeOffset _now;
|
|
|
|
public FixedTimeProvider(DateTimeOffset now)
|
|
{
|
|
_now = now;
|
|
}
|
|
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
}
|
|
}
|