Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using StellaOps.AirGap.Controller.Services;
|
||||
using StellaOps.AirGap.Controller.Stores;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class AirGapStateServiceTests
|
||||
{
|
||||
private readonly AirGapStateService _service;
|
||||
private readonly InMemoryAirGapStateStore _store = new();
|
||||
private readonly StalenessCalculator _calculator = new();
|
||||
|
||||
public AirGapStateServiceTests()
|
||||
{
|
||||
_service = new AirGapStateService(_store, _calculator);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Seal_sets_state_and_computes_staleness()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-2), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = new StalenessBudget(60, 120);
|
||||
|
||||
await _service.SealAsync("tenant-a", "policy-1", anchor, budget, now);
|
||||
var status = await _service.GetStatusAsync("tenant-a", now);
|
||||
|
||||
Assert.True(status.State.Sealed);
|
||||
Assert.Equal("policy-1", status.State.PolicyHash);
|
||||
Assert.Equal("tenant-a", status.State.TenantId);
|
||||
Assert.True(status.Staleness.AgeSeconds > 0);
|
||||
Assert.True(status.Staleness.IsWarning);
|
||||
Assert.Equal(120 - status.Staleness.AgeSeconds, status.Staleness.SecondsRemaining);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Unseal_clears_sealed_flag_and_updates_timestamp()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await _service.SealAsync("default", "hash", TimeAnchor.Unknown, StalenessBudget.Default, now);
|
||||
|
||||
var later = now.AddMinutes(1);
|
||||
await _service.UnsealAsync("default", later);
|
||||
var status = await _service.GetStatusAsync("default", later);
|
||||
|
||||
Assert.False(status.State.Sealed);
|
||||
Assert.Equal(later, status.State.LastTransitionAt);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Seal_persists_drift_baseline_seconds()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-5), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = StalenessBudget.Default;
|
||||
|
||||
var state = await _service.SealAsync("tenant-drift", "policy-drift", anchor, budget, now);
|
||||
|
||||
Assert.Equal(300, state.DriftBaselineSeconds); // 5 minutes = 300 seconds
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Seal_creates_default_content_budgets_when_not_provided()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = new StalenessBudget(120, 240);
|
||||
|
||||
var state = await _service.SealAsync("tenant-content", "policy-content", anchor, budget, now);
|
||||
|
||||
Assert.Contains("advisories", state.ContentBudgets.Keys);
|
||||
Assert.Contains("vex", state.ContentBudgets.Keys);
|
||||
Assert.Contains("policy", state.ContentBudgets.Keys);
|
||||
Assert.Equal(budget, state.ContentBudgets["advisories"]);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Seal_uses_provided_content_budgets()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = StalenessBudget.Default;
|
||||
var contentBudgets = new Dictionary<string, StalenessBudget>
|
||||
{
|
||||
{ "advisories", new StalenessBudget(30, 60) },
|
||||
{ "vex", new StalenessBudget(60, 120) }
|
||||
};
|
||||
|
||||
var state = await _service.SealAsync("tenant-custom", "policy-custom", anchor, budget, now, contentBudgets);
|
||||
|
||||
Assert.Equal(new StalenessBudget(30, 60), state.ContentBudgets["advisories"]);
|
||||
Assert.Equal(new StalenessBudget(60, 120), state.ContentBudgets["vex"]);
|
||||
Assert.Equal(budget, state.ContentBudgets["policy"]); // Falls back to default
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetStatus_returns_per_content_staleness()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var anchor = new TimeAnchor(now.AddSeconds(-45), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = StalenessBudget.Default;
|
||||
var contentBudgets = new Dictionary<string, StalenessBudget>
|
||||
{
|
||||
{ "advisories", new StalenessBudget(30, 60) },
|
||||
{ "vex", new StalenessBudget(60, 120) },
|
||||
{ "policy", new StalenessBudget(100, 200) }
|
||||
};
|
||||
|
||||
await _service.SealAsync("tenant-content-status", "policy-content-status", anchor, budget, now, contentBudgets);
|
||||
var status = await _service.GetStatusAsync("tenant-content-status", now);
|
||||
|
||||
Assert.NotEmpty(status.ContentStaleness);
|
||||
Assert.True(status.ContentStaleness["advisories"].IsWarning); // 45s >= 30s warning
|
||||
Assert.False(status.ContentStaleness["advisories"].IsBreach); // 45s < 60s breach
|
||||
Assert.False(status.ContentStaleness["vex"].IsWarning); // 45s < 60s warning
|
||||
Assert.False(status.ContentStaleness["policy"].IsWarning); // 45s < 100s warning
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
using StellaOps.AirGap.Controller.Stores;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class InMemoryAirGapStateStoreTests
|
||||
{
|
||||
private readonly InMemoryAirGapStateStore _store = new();
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Upsert_and_read_state_by_tenant()
|
||||
{
|
||||
var state = new AirGapState
|
||||
{
|
||||
TenantId = "tenant-x",
|
||||
Sealed = true,
|
||||
PolicyHash = "hash-1",
|
||||
TimeAnchor = new TimeAnchor(DateTimeOffset.UtcNow, "roughtime", "roughtime", "fp", "digest"),
|
||||
StalenessBudget = new StalenessBudget(10, 20),
|
||||
LastTransitionAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _store.SetAsync(state);
|
||||
|
||||
var stored = await _store.GetAsync("tenant-x");
|
||||
Assert.True(stored.Sealed);
|
||||
Assert.Equal("hash-1", stored.PolicyHash);
|
||||
Assert.Equal("tenant-x", stored.TenantId);
|
||||
Assert.Equal(state.TimeAnchor.TokenDigest, stored.TimeAnchor.TokenDigest);
|
||||
Assert.Equal(10, stored.StalenessBudget.WarningSeconds);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Enforces_singleton_per_tenant()
|
||||
{
|
||||
var first = new AirGapState { TenantId = "tenant-y", Sealed = true, PolicyHash = "h1" };
|
||||
var second = new AirGapState { TenantId = "tenant-y", Sealed = false, PolicyHash = "h2" };
|
||||
|
||||
await _store.SetAsync(first);
|
||||
await _store.SetAsync(second);
|
||||
|
||||
var stored = await _store.GetAsync("tenant-y");
|
||||
Assert.Equal("h2", stored.PolicyHash);
|
||||
Assert.False(stored.Sealed);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Defaults_to_unknown_when_missing()
|
||||
{
|
||||
var stored = await _store.GetAsync("absent");
|
||||
Assert.False(stored.Sealed);
|
||||
Assert.Equal("absent", stored.TenantId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Parallel_upserts_keep_single_document()
|
||||
{
|
||||
var tasks = Enumerable.Range(0, 20).Select(i =>
|
||||
{
|
||||
var state = new AirGapState
|
||||
{
|
||||
TenantId = "tenant-parallel",
|
||||
Sealed = i % 2 == 0,
|
||||
PolicyHash = $"hash-{i}"
|
||||
};
|
||||
return _store.SetAsync(state);
|
||||
});
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
var stored = await _store.GetAsync("tenant-parallel");
|
||||
Assert.StartsWith("hash-", stored.PolicyHash);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Multi_tenant_updates_do_not_collide()
|
||||
{
|
||||
var tenants = Enumerable.Range(0, 5).Select(i => $"t-{i}").ToArray();
|
||||
|
||||
var tasks = tenants.Select(t => _store.SetAsync(new AirGapState
|
||||
{
|
||||
TenantId = t,
|
||||
Sealed = true,
|
||||
PolicyHash = $"hash-{t}"
|
||||
}));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
foreach (var t in tenants)
|
||||
{
|
||||
var stored = await _store.GetAsync(t);
|
||||
Assert.Equal($"hash-{t}", stored.PolicyHash);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Staleness_round_trip_matches_budget()
|
||||
{
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UtcNow.AddMinutes(-3), "roughtime", "roughtime", "fp", "digest");
|
||||
var budget = new StalenessBudget(60, 600);
|
||||
await _store.SetAsync(new AirGapState
|
||||
{
|
||||
TenantId = "tenant-staleness",
|
||||
Sealed = true,
|
||||
PolicyHash = "hash-s",
|
||||
TimeAnchor = anchor,
|
||||
StalenessBudget = budget,
|
||||
LastTransitionAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
var stored = await _store.GetAsync("tenant-staleness");
|
||||
Assert.Equal(anchor.TokenDigest, stored.TimeAnchor.TokenDigest);
|
||||
Assert.Equal(budget.WarningSeconds, stored.StalenessBudget.WarningSeconds);
|
||||
Assert.Equal(budget.BreachSeconds, stored.StalenessBudget.BreachSeconds);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Multi_tenant_states_preserve_transition_times()
|
||||
{
|
||||
var tenants = new[] { "a", "b", "c" };
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
foreach (var t in tenants)
|
||||
{
|
||||
await _store.SetAsync(new AirGapState
|
||||
{
|
||||
TenantId = t,
|
||||
Sealed = true,
|
||||
PolicyHash = $"ph-{t}",
|
||||
LastTransitionAt = now
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var t in tenants)
|
||||
{
|
||||
var state = await _store.GetAsync(t);
|
||||
Assert.Equal(now, state.LastTransitionAt);
|
||||
Assert.Equal($"ph-{t}", state.PolicyHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using StellaOps.AirGap.Controller.Endpoints.Contracts;
|
||||
using StellaOps.AirGap.Controller.Services;
|
||||
using StellaOps.AirGap.Controller.Stores;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class ReplayVerificationServiceTests
|
||||
{
|
||||
private readonly ReplayVerificationService _service;
|
||||
private readonly AirGapStateService _stateService;
|
||||
private readonly StalenessCalculator _staleness = new();
|
||||
private readonly InMemoryAirGapStateStore _store = new();
|
||||
|
||||
public ReplayVerificationServiceTests()
|
||||
{
|
||||
_stateService = new AirGapStateService(_store, _staleness);
|
||||
_service = new ReplayVerificationService(_stateService, new ReplayVerifier());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Passes_full_recompute_when_hashes_match()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-12-02T01:00:00Z");
|
||||
await _stateService.SealAsync("tenant-a", "policy-x", TimeAnchor.Unknown, StalenessBudget.Default, now);
|
||||
|
||||
var request = new VerifyRequest
|
||||
{
|
||||
Depth = ReplayDepth.FullRecompute,
|
||||
ManifestSha256 = new string('a', 64),
|
||||
BundleSha256 = new string('b', 64),
|
||||
ComputedManifestSha256 = new string('a', 64),
|
||||
ComputedBundleSha256 = new string('b', 64),
|
||||
ManifestCreatedAt = now.AddHours(-2),
|
||||
StalenessWindowHours = 24,
|
||||
BundlePolicyHash = "policy-x"
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync("tenant-a", request, now);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("full-recompute-passed", result.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Detects_stale_manifest()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var request = new VerifyRequest
|
||||
{
|
||||
Depth = ReplayDepth.HashOnly,
|
||||
ManifestSha256 = new string('a', 64),
|
||||
BundleSha256 = new string('b', 64),
|
||||
ComputedManifestSha256 = new string('a', 64),
|
||||
ComputedBundleSha256 = new string('b', 64),
|
||||
ManifestCreatedAt = now.AddHours(-30),
|
||||
StalenessWindowHours = 12
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync("default", request, now);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("manifest-stale", result.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Policy_freeze_requires_matching_policy()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await _stateService.SealAsync("tenant-b", "sealed-policy", TimeAnchor.Unknown, StalenessBudget.Default, now);
|
||||
|
||||
var request = new VerifyRequest
|
||||
{
|
||||
Depth = ReplayDepth.PolicyFreeze,
|
||||
ManifestSha256 = new string('a', 64),
|
||||
BundleSha256 = new string('b', 64),
|
||||
ComputedManifestSha256 = new string('a', 64),
|
||||
ComputedBundleSha256 = new string('b', 64),
|
||||
ManifestCreatedAt = now,
|
||||
StalenessWindowHours = 48,
|
||||
BundlePolicyHash = "bundle-policy"
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync("tenant-b", request, now);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("policy-hash-drift", result.Reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.AirGap.Controller/StellaOps.AirGap.Controller.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<Compile Include="../../shared/*.cs" Link="Shared/%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,4 +1,4 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------------
|
||||
// AirGapControllerContractTests.cs
|
||||
// Sprint: SPRINT_5100_0010_0004_airgap_tests
|
||||
// Tasks: AIRGAP-5100-010, AIRGAP-5100-011, AIRGAP-5100-012
|
||||
@@ -364,7 +364,6 @@ public sealed class AirGapControllerContractTests
|
||||
{
|
||||
// Arrange - Create a trace context
|
||||
using var activity = new Activity("test-airgap-operation");
|
||||
using StellaOps.TestKit;
|
||||
activity.Start();
|
||||
|
||||
// Act
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Planning;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Importer.Tests;
|
||||
|
||||
public class BundleImportPlannerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ReturnsFailureWhenBundlePathMissing()
|
||||
{
|
||||
var planner = new BundleImportPlanner();
|
||||
var result = planner.CreatePlan(string.Empty, TrustRootConfig.Empty("/tmp"));
|
||||
|
||||
Assert.False(result.InitialState.IsValid);
|
||||
Assert.Equal("bundle-path-required", result.InitialState.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ReturnsFailureWhenTrustRootsMissing()
|
||||
{
|
||||
var planner = new BundleImportPlanner();
|
||||
var result = planner.CreatePlan("bundle.tar", TrustRootConfig.Empty("/tmp"));
|
||||
|
||||
Assert.False(result.InitialState.IsValid);
|
||||
Assert.Equal("trust-roots-required", result.InitialState.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ReturnsDefaultPlanWhenInputsProvided()
|
||||
{
|
||||
var planner = new BundleImportPlanner();
|
||||
var trust = new TrustRootConfig("/tmp/trust.json", new[] { "abc" }, new[] { "ed25519" }, null, null, new Dictionary<string, byte[]>());
|
||||
|
||||
var result = planner.CreatePlan("bundle.tar", trust);
|
||||
|
||||
Assert.True(result.InitialState.IsValid);
|
||||
Assert.Contains("verify-dsse-signature", result.Steps);
|
||||
Assert.Equal("bundle.tar", result.Inputs["bundlePath"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Importer.Tests;
|
||||
|
||||
public class DsseVerifierTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FailsWhenUntrustedKey()
|
||||
{
|
||||
var verifier = new DsseVerifier();
|
||||
var envelope = new DsseEnvelope("text/plain", Convert.ToBase64String("hi"u8), new[] { new DsseSignature("k1", "sig") });
|
||||
var trust = TrustRootConfig.Empty("/tmp");
|
||||
|
||||
var result = verifier.Verify(envelope, trust);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void VerifiesRsaPssSignature()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var pub = rsa.ExportSubjectPublicKeyInfo();
|
||||
var payload = "hello-world";
|
||||
var payloadType = "application/vnd.stella.bundle";
|
||||
var pae = BuildPae(payloadType, payload);
|
||||
var sig = rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
|
||||
var envelope = new DsseEnvelope(payloadType, Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload)), new[]
|
||||
{
|
||||
new DsseSignature("k1", Convert.ToBase64String(sig))
|
||||
});
|
||||
|
||||
var trust = new TrustRootConfig(
|
||||
"/tmp/root.json",
|
||||
new[] { Fingerprint(pub) },
|
||||
new[] { "rsassa-pss-sha256" },
|
||||
null,
|
||||
null,
|
||||
new Dictionary<string, byte[]> { ["k1"] = pub });
|
||||
|
||||
var result = new DsseVerifier().Verify(envelope, trust);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("dsse-signature-verified", result.Reason);
|
||||
}
|
||||
|
||||
private static byte[] BuildPae(string payloadType, string payload)
|
||||
{
|
||||
var parts = new[] { "DSSEv1", payloadType, payload };
|
||||
var paeBuilder = new System.Text.StringBuilder();
|
||||
paeBuilder.Append("PAE:");
|
||||
paeBuilder.Append(parts.Length);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
paeBuilder.Append(' ');
|
||||
paeBuilder.Append(part.Length);
|
||||
paeBuilder.Append(' ');
|
||||
paeBuilder.Append(part);
|
||||
}
|
||||
|
||||
return System.Text.Encoding.UTF8.GetBytes(paeBuilder.ToString());
|
||||
}
|
||||
|
||||
private static string Fingerprint(byte[] pub)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(pub)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Security.Cryptography;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Quarantine;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
using StellaOps.AirGap.Importer.Versioning;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Importer.Tests;
|
||||
|
||||
public sealed class ImportValidatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenTufInvalid_ShouldFailAndQuarantine()
|
||||
{
|
||||
var quarantine = new CapturingQuarantineService();
|
||||
var monotonicity = new CapturingMonotonicityChecker();
|
||||
|
||||
var validator = new ImportValidator(
|
||||
new DsseVerifier(),
|
||||
new TufMetadataValidator(),
|
||||
new MerkleRootCalculator(),
|
||||
new RootRotationPolicy(),
|
||||
monotonicity,
|
||||
quarantine,
|
||||
NullLogger<ImportValidator>.Instance);
|
||||
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
var bundlePath = Path.Combine(tempRoot, "bundle.tar.zst");
|
||||
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
|
||||
|
||||
try
|
||||
{
|
||||
var request = BuildRequest(bundlePath, rootJson: "{}", snapshotJson: "{}", timestampJson: "{}");
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().StartWith("tuf:");
|
||||
|
||||
quarantine.Requests.Should().HaveCount(1);
|
||||
quarantine.Requests[0].TenantId.Should().Be("tenant-a");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenAllChecksPass_ShouldSucceedAndRecordActivation()
|
||||
{
|
||||
var root = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\"}";
|
||||
var snapshot = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"meta\":{\"snapshot\":{\"hashes\":{\"sha256\":\"abc\"}}}}";
|
||||
var timestamp = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"snapshot\":{\"meta\":{\"hashes\":{\"sha256\":\"abc\"}}}}";
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
var pub = rsa.ExportSubjectPublicKeyInfo();
|
||||
|
||||
var payload = "bundle-body";
|
||||
var payloadType = "application/vnd.stella.bundle";
|
||||
var pae = BuildPae(payloadType, payload);
|
||||
var sig = rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
|
||||
|
||||
var envelope = new DsseEnvelope(payloadType, Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload)), new[]
|
||||
{
|
||||
new DsseSignature("k1", Convert.ToBase64String(sig))
|
||||
});
|
||||
|
||||
var trustStore = new TrustStore();
|
||||
trustStore.LoadActive(new Dictionary<string, byte[]> { ["k1"] = pub });
|
||||
trustStore.StagePending(new Dictionary<string, byte[]> { ["k2"] = pub });
|
||||
|
||||
var quarantine = new CapturingQuarantineService();
|
||||
var monotonicity = new CapturingMonotonicityChecker();
|
||||
|
||||
var validator = new ImportValidator(
|
||||
new DsseVerifier(),
|
||||
new TufMetadataValidator(),
|
||||
new MerkleRootCalculator(),
|
||||
new RootRotationPolicy(),
|
||||
monotonicity,
|
||||
quarantine,
|
||||
NullLogger<ImportValidator>.Instance);
|
||||
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
var bundlePath = Path.Combine(tempRoot, "bundle.tar.zst");
|
||||
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
|
||||
|
||||
try
|
||||
{
|
||||
var request = new ImportValidationRequest(
|
||||
TenantId: "tenant-a",
|
||||
BundleType: "offline-kit",
|
||||
BundleDigest: "sha256:bundle",
|
||||
BundlePath: bundlePath,
|
||||
ManifestJson: "{\"version\":\"1.0.0\"}",
|
||||
ManifestVersion: "1.0.0",
|
||||
ManifestCreatedAt: DateTimeOffset.Parse("2025-12-15T00:00:00Z"),
|
||||
ForceActivate: false,
|
||||
ForceActivateReason: null,
|
||||
Envelope: envelope,
|
||||
TrustRoots: new TrustRootConfig("/tmp/root.json", new[] { Fingerprint(pub) }, new[] { "rsassa-pss-sha256" }, null, null, new Dictionary<string, byte[]> { ["k1"] = pub }),
|
||||
RootJson: root,
|
||||
SnapshotJson: snapshot,
|
||||
TimestampJson: timestamp,
|
||||
PayloadEntries: new List<NamedStream> { new("a.txt", new MemoryStream("data"u8.ToArray())) },
|
||||
TrustStore: trustStore,
|
||||
ApproverIds: new[] { "approver-1", "approver-2" });
|
||||
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Reason.Should().Be("import-validated");
|
||||
|
||||
monotonicity.RecordedActivations.Should().HaveCount(1);
|
||||
monotonicity.RecordedActivations[0].BundleDigest.Should().Be("sha256:bundle");
|
||||
monotonicity.RecordedActivations[0].Version.SemVer.Should().Be("1.0.0");
|
||||
|
||||
quarantine.Requests.Should().BeEmpty();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildPae(string payloadType, string payload)
|
||||
{
|
||||
var parts = new[] { "DSSEv1", payloadType, payload };
|
||||
var paeBuilder = new System.Text.StringBuilder();
|
||||
paeBuilder.Append("PAE:");
|
||||
paeBuilder.Append(parts.Length);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
paeBuilder.Append(' ');
|
||||
paeBuilder.Append(part.Length);
|
||||
paeBuilder.Append(' ');
|
||||
paeBuilder.Append(part);
|
||||
}
|
||||
|
||||
return System.Text.Encoding.UTF8.GetBytes(paeBuilder.ToString());
|
||||
}
|
||||
|
||||
private static string Fingerprint(byte[] pub) => Convert.ToHexString(SHA256.HashData(pub)).ToLowerInvariant();
|
||||
|
||||
private static ImportValidationRequest BuildRequest(string bundlePath, string rootJson, string snapshotJson, string timestampJson)
|
||||
{
|
||||
var envelope = new DsseEnvelope("text/plain", Convert.ToBase64String("hi"u8), Array.Empty<DsseSignature>());
|
||||
var trustRoot = TrustRootConfig.Empty("/tmp");
|
||||
var trustStore = new TrustStore();
|
||||
return new ImportValidationRequest(
|
||||
TenantId: "tenant-a",
|
||||
BundleType: "offline-kit",
|
||||
BundleDigest: "sha256:bundle",
|
||||
BundlePath: bundlePath,
|
||||
ManifestJson: null,
|
||||
ManifestVersion: "1.0.0",
|
||||
ManifestCreatedAt: DateTimeOffset.Parse("2025-12-15T00:00:00Z"),
|
||||
ForceActivate: false,
|
||||
ForceActivateReason: null,
|
||||
Envelope: envelope,
|
||||
TrustRoots: trustRoot,
|
||||
RootJson: rootJson,
|
||||
SnapshotJson: snapshotJson,
|
||||
TimestampJson: timestampJson,
|
||||
PayloadEntries: Array.Empty<NamedStream>(),
|
||||
TrustStore: trustStore,
|
||||
ApproverIds: Array.Empty<string>());
|
||||
}
|
||||
|
||||
private sealed class CapturingMonotonicityChecker : IVersionMonotonicityChecker
|
||||
{
|
||||
public List<(BundleVersion Version, string BundleDigest)> RecordedActivations { get; } = new();
|
||||
|
||||
public Task<MonotonicityCheckResult> CheckAsync(string tenantId, string bundleType, BundleVersion incomingVersion, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new MonotonicityCheckResult(
|
||||
IsMonotonic: true,
|
||||
CurrentVersion: null,
|
||||
CurrentBundleDigest: null,
|
||||
CurrentActivatedAt: null,
|
||||
ReasonCode: "FIRST_ACTIVATION"));
|
||||
}
|
||||
|
||||
public Task RecordActivationAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion version,
|
||||
string bundleDigest,
|
||||
bool wasForceActivated = false,
|
||||
string? forceActivateReason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RecordedActivations.Add((version, bundleDigest));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingQuarantineService : IQuarantineService
|
||||
{
|
||||
public List<QuarantineRequest> Requests { get; } = new();
|
||||
|
||||
public Task<QuarantineResult> QuarantineAsync(QuarantineRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Requests.Add(request);
|
||||
return Task.FromResult(new QuarantineResult(
|
||||
Success: true,
|
||||
QuarantineId: "test",
|
||||
QuarantinePath: "(memory)",
|
||||
QuarantinedAt: DateTimeOffset.UnixEpoch));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<QuarantineEntry>> ListAsync(string tenantId, QuarantineListOptions? options = null, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<QuarantineEntry>>(Array.Empty<QuarantineEntry>());
|
||||
|
||||
public Task<bool> RemoveAsync(string tenantId, string quarantineId, string removalReason, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(false);
|
||||
|
||||
public Task<int> CleanupExpiredAsync(TimeSpan retentionPeriod, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using StellaOps.AirGap.Importer.Models;
|
||||
using StellaOps.AirGap.Importer.Repositories;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Importer.Tests;
|
||||
|
||||
public class InMemoryBundleRepositoriesTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CatalogUpsertOverwritesPerTenant()
|
||||
{
|
||||
var repo = new InMemoryBundleCatalogRepository();
|
||||
var entry1 = new BundleCatalogEntry("t1", "b1", "d1", DateTimeOffset.UnixEpoch, new[] { "a" });
|
||||
var entry2 = new BundleCatalogEntry("t1", "b1", "d2", DateTimeOffset.UnixEpoch.AddMinutes(1), new[] { "b" });
|
||||
|
||||
await repo.UpsertAsync(entry1, default);
|
||||
await repo.UpsertAsync(entry2, default);
|
||||
|
||||
var list = await repo.ListAsync("t1", default);
|
||||
Assert.Single(list);
|
||||
Assert.Equal("d2", list[0].Digest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CatalogIsTenantIsolated()
|
||||
{
|
||||
var repo = new InMemoryBundleCatalogRepository();
|
||||
await repo.UpsertAsync(new BundleCatalogEntry("t1", "b1", "d1", DateTimeOffset.UnixEpoch, Array.Empty<string>()), default);
|
||||
await repo.UpsertAsync(new BundleCatalogEntry("t2", "b1", "d2", DateTimeOffset.UnixEpoch, Array.Empty<string>()), default);
|
||||
|
||||
var t1 = await repo.ListAsync("t1", default);
|
||||
Assert.Single(t1);
|
||||
Assert.Equal("d1", t1[0].Digest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ItemsOrderedByPath()
|
||||
{
|
||||
var repo = new InMemoryBundleItemRepository();
|
||||
await repo.UpsertManyAsync(new[]
|
||||
{
|
||||
new BundleItem("t1", "b1", "b.txt", "d2", 10),
|
||||
new BundleItem("t1", "b1", "a.txt", "d1", 5)
|
||||
}, default);
|
||||
|
||||
var list = await repo.ListByBundleAsync("t1", "b1", default);
|
||||
Assert.Equal(new[] { "a.txt", "b.txt" }, list.Select(i => i.Path).ToArray());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ItemsTenantIsolated()
|
||||
{
|
||||
var repo = new InMemoryBundleItemRepository();
|
||||
await repo.UpsertManyAsync(new[]
|
||||
{
|
||||
new BundleItem("t1", "b1", "a.txt", "d1", 1),
|
||||
new BundleItem("t2", "b1", "a.txt", "d2", 1)
|
||||
}, default);
|
||||
|
||||
var list = await repo.ListByBundleAsync("t1", "b1", default);
|
||||
Assert.Single(list);
|
||||
Assert.Equal("d1", list[0].Digest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Importer.Tests;
|
||||
|
||||
public class MerkleRootCalculatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EmptySetProducesEmptyRoot()
|
||||
{
|
||||
var calc = new MerkleRootCalculator();
|
||||
var root = calc.ComputeRoot(Array.Empty<NamedStream>());
|
||||
Assert.Equal(string.Empty, root);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DeterministicAcrossOrder()
|
||||
{
|
||||
var calc = new MerkleRootCalculator();
|
||||
var a = new NamedStream("b.txt", new MemoryStream("two"u8.ToArray()));
|
||||
var b = new NamedStream("a.txt", new MemoryStream("one"u8.ToArray()));
|
||||
|
||||
var root1 = calc.ComputeRoot(new[] { a, b });
|
||||
var root2 = calc.ComputeRoot(new[] { b, a });
|
||||
|
||||
Assert.Equal(root1, root2);
|
||||
Assert.NotEqual(string.Empty, root1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using StellaOps.AirGap.Importer.Telemetry;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Importer.Tests;
|
||||
|
||||
public sealed class OfflineKitMetricsTests : IDisposable
|
||||
{
|
||||
private readonly MeterListener _listener;
|
||||
private readonly List<RecordedMeasurement> _measurements = [];
|
||||
|
||||
public OfflineKitMetricsTests()
|
||||
{
|
||||
_listener = new MeterListener();
|
||||
_listener.InstrumentPublished = (instrument, listener) =>
|
||||
{
|
||||
if (instrument.Meter.Name == OfflineKitMetrics.MeterName)
|
||||
{
|
||||
listener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
};
|
||||
|
||||
_listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
_measurements.Add(new RecordedMeasurement(instrument.Name, measurement, tags.ToArray()));
|
||||
});
|
||||
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
_measurements.Add(new RecordedMeasurement(instrument.Name, measurement, tags.ToArray()));
|
||||
});
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
public void Dispose() => _listener.Dispose();
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RecordImport_EmitsCounterWithLabels()
|
||||
{
|
||||
using var metrics = new OfflineKitMetrics();
|
||||
|
||||
metrics.RecordImport(status: "success", tenantId: "tenant-a");
|
||||
|
||||
Assert.Contains(_measurements, m =>
|
||||
m.Name == "offlinekit_import_total" &&
|
||||
m.Value is long v &&
|
||||
v == 1 &&
|
||||
m.HasTag(OfflineKitMetrics.TagNames.Status, "success") &&
|
||||
m.HasTag(OfflineKitMetrics.TagNames.TenantId, "tenant-a"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RecordAttestationVerifyLatency_EmitsHistogramWithLabels()
|
||||
{
|
||||
using var metrics = new OfflineKitMetrics();
|
||||
|
||||
metrics.RecordAttestationVerifyLatency(attestationType: "dsse", seconds: 1.234, success: true);
|
||||
|
||||
Assert.Contains(_measurements, m =>
|
||||
m.Name == "offlinekit_attestation_verify_latency_seconds" &&
|
||||
m.Value is double v &&
|
||||
Math.Abs(v - 1.234) < 0.000_001 &&
|
||||
m.HasTag(OfflineKitMetrics.TagNames.AttestationType, "dsse") &&
|
||||
m.HasTag(OfflineKitMetrics.TagNames.Success, "true"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RecordRekorSuccess_EmitsCounterWithLabels()
|
||||
{
|
||||
using var metrics = new OfflineKitMetrics();
|
||||
|
||||
metrics.RecordRekorSuccess(mode: "offline");
|
||||
|
||||
Assert.Contains(_measurements, m =>
|
||||
m.Name == "attestor_rekor_success_total" &&
|
||||
m.Value is long v &&
|
||||
v == 1 &&
|
||||
m.HasTag(OfflineKitMetrics.TagNames.Mode, "offline"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RecordRekorRetry_EmitsCounterWithLabels()
|
||||
{
|
||||
using var metrics = new OfflineKitMetrics();
|
||||
|
||||
metrics.RecordRekorRetry(reason: "stale_snapshot");
|
||||
|
||||
Assert.Contains(_measurements, m =>
|
||||
m.Name == "attestor_rekor_retry_total" &&
|
||||
m.Value is long v &&
|
||||
v == 1 &&
|
||||
m.HasTag(OfflineKitMetrics.TagNames.Reason, "stale_snapshot"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RecordRekorInclusionLatency_EmitsHistogramWithLabels()
|
||||
{
|
||||
using var metrics = new OfflineKitMetrics();
|
||||
|
||||
metrics.RecordRekorInclusionLatency(seconds: 0.5, success: false);
|
||||
|
||||
Assert.Contains(_measurements, m =>
|
||||
m.Name == "rekor_inclusion_latency" &&
|
||||
m.Value is double v &&
|
||||
Math.Abs(v - 0.5) < 0.000_001 &&
|
||||
m.HasTag(OfflineKitMetrics.TagNames.Success, "false"));
|
||||
}
|
||||
|
||||
private sealed record RecordedMeasurement(string Name, object Value, IReadOnlyList<KeyValuePair<string, object?>> Tags)
|
||||
{
|
||||
public bool HasTag(string key, string expectedValue) =>
|
||||
Tags.Any(t => t.Key == key && string.Equals(t.Value?.ToString(), expectedValue, StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,23 +8,21 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" >
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\StellaOps.AirGap.Importer\\StellaOps.AirGap.Importer.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,127 @@
|
||||
using System.Reflection;
|
||||
using Npgsql;
|
||||
using StellaOps.AirGap.Persistence.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for the AirGap module.
|
||||
/// Runs migrations from embedded resources and provides test isolation.
|
||||
/// </summary>
|
||||
public sealed class AirGapPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<AirGapPostgresFixture>
|
||||
{
|
||||
protected override Assembly? GetMigrationAssembly()
|
||||
=> typeof(AirGapDataSource).Assembly;
|
||||
|
||||
protected override string GetModuleName() => "AirGap";
|
||||
|
||||
protected override string? GetResourcePrefix() => "Migrations";
|
||||
|
||||
/// <summary>
|
||||
/// Gets all table names in the test schema.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> GetTableNamesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = @schema AND table_type = 'BASE TABLE';
|
||||
""",
|
||||
connection);
|
||||
cmd.Parameters.AddWithValue("schema", SchemaName);
|
||||
|
||||
var tables = new List<string>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
tables.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all column names for a specific table in the test schema.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> GetColumnNamesAsync(string tableName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = @schema AND table_name = @table;
|
||||
""",
|
||||
connection);
|
||||
cmd.Parameters.AddWithValue("schema", SchemaName);
|
||||
cmd.Parameters.AddWithValue("table", tableName);
|
||||
|
||||
var columns = new List<string>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
columns.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all index names for a specific table in the test schema.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> GetIndexNamesAsync(string tableName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE schemaname = @schema AND tablename = @table;
|
||||
""",
|
||||
connection);
|
||||
cmd.Parameters.AddWithValue("schema", SchemaName);
|
||||
cmd.Parameters.AddWithValue("table", tableName);
|
||||
|
||||
var indexes = new List<string>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
indexes.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return indexes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures migrations have been run. This is idempotent and safe to call multiple times.
|
||||
/// </summary>
|
||||
public async Task EnsureMigrationsRunAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var migrationAssembly = GetMigrationAssembly();
|
||||
if (migrationAssembly != null)
|
||||
{
|
||||
await Fixture.RunMigrationsFromAssemblyAsync(
|
||||
migrationAssembly,
|
||||
GetModuleName(),
|
||||
GetResourcePrefix(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for AirGap PostgreSQL integration tests.
|
||||
/// Tests in this collection share a single PostgreSQL container instance.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class AirGapPostgresCollection : ICollectionFixture<AirGapPostgresFixture>
|
||||
{
|
||||
public const string Name = "AirGapPostgres";
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AirGapStorageIntegrationTests.cs
|
||||
// Sprint: SPRINT_5100_0010_0004_airgap_tests
|
||||
// Tasks: AIRGAP-5100-007, AIRGAP-5100-008, AIRGAP-5100-009
|
||||
// Description: S1 Storage tests - migrations, idempotency, query determinism
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
using StellaOps.AirGap.Persistence.Postgres;
|
||||
using StellaOps.AirGap.Persistence.Postgres.Repositories;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// S1 Storage Layer Tests for AirGap
|
||||
/// Task AIRGAP-5100-007: Migration tests (apply from scratch, apply from N-1)
|
||||
/// Task AIRGAP-5100-008: Idempotency tests (same bundle imported twice → no duplicates)
|
||||
/// Task AIRGAP-5100-009: Query determinism tests (explicit ORDER BY checks)
|
||||
/// </summary>
|
||||
[Collection(AirGapPostgresCollection.Name)]
|
||||
public sealed class AirGapStorageIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly AirGapPostgresFixture _fixture;
|
||||
private readonly PostgresAirGapStateStore _store;
|
||||
private readonly AirGapDataSource _dataSource;
|
||||
|
||||
public AirGapStorageIntegrationTests(AirGapPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
var options = Options.Create(new PostgresOptions
|
||||
{
|
||||
ConnectionString = fixture.ConnectionString,
|
||||
SchemaName = AirGapDataSource.DefaultSchemaName,
|
||||
AutoMigrate = false
|
||||
});
|
||||
|
||||
_dataSource = new AirGapDataSource(options, NullLogger<AirGapDataSource>.Instance);
|
||||
_store = new PostgresAirGapStateStore(_dataSource, NullLogger<PostgresAirGapStateStore>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
#region AIRGAP-5100-007: Migration Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Migration_SchemaContainsRequiredTables()
|
||||
{
|
||||
// Arrange
|
||||
var expectedTables = new[]
|
||||
{
|
||||
"airgap_state",
|
||||
"airgap_bundles",
|
||||
"airgap_import_log"
|
||||
};
|
||||
|
||||
// Act
|
||||
var tables = await _fixture.GetTableNamesAsync();
|
||||
|
||||
// Assert
|
||||
foreach (var expectedTable in expectedTables)
|
||||
{
|
||||
tables.Should().Contain(t => t.Contains(expectedTable, StringComparison.OrdinalIgnoreCase),
|
||||
$"Table '{expectedTable}' should exist in schema");
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Migration_AirGapStateHasRequiredColumns()
|
||||
{
|
||||
// Arrange
|
||||
var expectedColumns = new[] { "tenant_id", "sealed", "policy_hash", "time_anchor", "created_at", "updated_at" };
|
||||
|
||||
// Act
|
||||
var columns = await _fixture.GetColumnNamesAsync("airgap_state");
|
||||
|
||||
// Assert
|
||||
foreach (var expectedColumn in expectedColumns)
|
||||
{
|
||||
columns.Should().Contain(c => c.Contains(expectedColumn, StringComparison.OrdinalIgnoreCase),
|
||||
$"Column '{expectedColumn}' should exist in airgap_state");
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Migration_IsIdempotent()
|
||||
{
|
||||
// Act - Running migrations again should not fail
|
||||
var act = async () =>
|
||||
{
|
||||
await _fixture.EnsureMigrationsRunAsync();
|
||||
};
|
||||
|
||||
// Assert
|
||||
await act.Should().NotThrowAsync("Running migrations multiple times should be idempotent");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Migration_HasTenantIndex()
|
||||
{
|
||||
// Act
|
||||
var indexes = await _fixture.GetIndexNamesAsync("airgap_state");
|
||||
|
||||
// Assert
|
||||
indexes.Should().Contain(i => i.Contains("tenant", StringComparison.OrdinalIgnoreCase),
|
||||
"airgap_state should have tenant index for multi-tenant queries");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AIRGAP-5100-008: Idempotency Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Idempotency_SetStateTwice_NoException()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = $"tenant-idem-{Guid.NewGuid():N}";
|
||||
var state = CreateTestState(tenantId);
|
||||
|
||||
// Act - Set state twice
|
||||
await _store.SetAsync(state);
|
||||
var act = async () => await _store.SetAsync(state);
|
||||
|
||||
// Assert
|
||||
await act.Should().NotThrowAsync("Setting state twice should be idempotent");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Idempotency_SetStateTwice_SingleRecord()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = $"tenant-single-{Guid.NewGuid():N}";
|
||||
var state1 = CreateTestState(tenantId, sealed_: true, policyHash: "sha256:policy-v1");
|
||||
var state2 = CreateTestState(tenantId, sealed_: true, policyHash: "sha256:policy-v2");
|
||||
|
||||
// Act
|
||||
await _store.SetAsync(state1);
|
||||
await _store.SetAsync(state2);
|
||||
var fetched = await _store.GetAsync(tenantId);
|
||||
|
||||
// Assert - Should have latest value, not duplicate
|
||||
fetched.PolicyHash.Should().Be("sha256:policy-v2", "Second set should update, not duplicate");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Idempotency_ConcurrentSets_NoDataCorruption()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = $"tenant-concurrent-{Guid.NewGuid():N}";
|
||||
var tasks = new List<Task>();
|
||||
|
||||
// Act - Concurrent sets
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var iteration = i;
|
||||
tasks.Add(Task.Run(async () =>
|
||||
{
|
||||
var state = CreateTestState(tenantId, sealed_: iteration % 2 == 0, policyHash: $"sha256:policy-{iteration}");
|
||||
await _store.SetAsync(state);
|
||||
}));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert - Should have valid state (no corruption)
|
||||
var fetched = await _store.GetAsync(tenantId);
|
||||
fetched.Should().NotBeNull();
|
||||
fetched.TenantId.Should().Be(tenantId);
|
||||
fetched.PolicyHash.Should().StartWith("sha256:policy-");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Idempotency_SameBundleIdTwice_NoException()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = $"tenant-bundle-{Guid.NewGuid():N}";
|
||||
var bundleId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Create state with bundle reference
|
||||
var state = CreateTestState(tenantId, sealed_: true);
|
||||
|
||||
// Act - Set same state twice (simulating duplicate bundle import)
|
||||
await _store.SetAsync(state);
|
||||
var act = async () => await _store.SetAsync(state);
|
||||
|
||||
// Assert
|
||||
await act.Should().NotThrowAsync("Importing same bundle twice should be idempotent");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AIRGAP-5100-009: Query Determinism Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task QueryDeterminism_SameInput_SameOutput()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = $"tenant-det-{Guid.NewGuid():N}";
|
||||
var state = CreateTestState(tenantId);
|
||||
await _store.SetAsync(state);
|
||||
|
||||
// Act - Query multiple times
|
||||
var result1 = await _store.GetAsync(tenantId);
|
||||
var result2 = await _store.GetAsync(tenantId);
|
||||
var result3 = await _store.GetAsync(tenantId);
|
||||
|
||||
// Assert - All results should be equivalent
|
||||
result1.Should().BeEquivalentTo(result2);
|
||||
result2.Should().BeEquivalentTo(result3);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task QueryDeterminism_ContentBudgets_ReturnInConsistentOrder()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = $"tenant-budgets-{Guid.NewGuid():N}";
|
||||
var state = CreateTestState(tenantId) with
|
||||
{
|
||||
ContentBudgets = new Dictionary<string, StalenessBudget>
|
||||
{
|
||||
["zebra"] = new StalenessBudget(100, 200),
|
||||
["alpha"] = new StalenessBudget(300, 400),
|
||||
["middle"] = new StalenessBudget(500, 600)
|
||||
}
|
||||
};
|
||||
await _store.SetAsync(state);
|
||||
|
||||
// Act - Query multiple times
|
||||
var results = new List<IReadOnlyDictionary<string, StalenessBudget>>();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var fetched = await _store.GetAsync(tenantId);
|
||||
results.Add(fetched.ContentBudgets);
|
||||
}
|
||||
|
||||
// Assert - All queries should return same keys
|
||||
var keys1 = results[0].Keys.OrderBy(k => k).ToList();
|
||||
foreach (var result in results.Skip(1))
|
||||
{
|
||||
var keys = result.Keys.OrderBy(k => k).ToList();
|
||||
keys.Should().BeEquivalentTo(keys1, options => options.WithStrictOrdering());
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task QueryDeterminism_TimeAnchor_PreservesAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = $"tenant-anchor-{Guid.NewGuid():N}";
|
||||
var anchorTime = DateTimeOffset.Parse("2025-06-15T12:00:00Z");
|
||||
var state = CreateTestState(tenantId) with
|
||||
{
|
||||
TimeAnchor = new TimeAnchor(
|
||||
anchorTime,
|
||||
"tsa.example.com",
|
||||
"RFC3161",
|
||||
"sha256:fingerprint",
|
||||
"sha256:tokendigest")
|
||||
};
|
||||
await _store.SetAsync(state);
|
||||
|
||||
// Act
|
||||
var fetched1 = await _store.GetAsync(tenantId);
|
||||
var fetched2 = await _store.GetAsync(tenantId);
|
||||
|
||||
// Assert
|
||||
fetched1.TimeAnchor.Should().BeEquivalentTo(fetched2.TimeAnchor);
|
||||
fetched1.TimeAnchor.AnchorTime.Should().Be(anchorTime);
|
||||
fetched1.TimeAnchor.Source.Should().Be("tsa.example.com");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task QueryDeterminism_MultipleTenants_IsolatedResults()
|
||||
{
|
||||
// Arrange
|
||||
var tenant1 = $"tenant-iso1-{Guid.NewGuid():N}";
|
||||
var tenant2 = $"tenant-iso2-{Guid.NewGuid():N}";
|
||||
|
||||
await _store.SetAsync(CreateTestState(tenant1, sealed_: true, policyHash: "sha256:tenant1-policy"));
|
||||
await _store.SetAsync(CreateTestState(tenant2, sealed_: false, policyHash: "sha256:tenant2-policy"));
|
||||
|
||||
// Act
|
||||
var result1 = await _store.GetAsync(tenant1);
|
||||
var result2 = await _store.GetAsync(tenant2);
|
||||
|
||||
// Assert
|
||||
result1.Sealed.Should().BeTrue();
|
||||
result1.PolicyHash.Should().Be("sha256:tenant1-policy");
|
||||
result2.Sealed.Should().BeFalse();
|
||||
result2.PolicyHash.Should().Be("sha256:tenant2-policy");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static AirGapState CreateTestState(string tenantId, bool sealed_ = false, string? policyHash = null)
|
||||
{
|
||||
return new AirGapState
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
TenantId = tenantId,
|
||||
Sealed = sealed_,
|
||||
PolicyHash = policyHash,
|
||||
LastTransitionAt = DateTimeOffset.UtcNow,
|
||||
StalenessBudget = new StalenessBudget(1800, 3600),
|
||||
DriftBaselineSeconds = 5,
|
||||
ContentBudgets = new Dictionary<string, StalenessBudget>()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
using StellaOps.AirGap.Persistence.Postgres;
|
||||
using StellaOps.AirGap.Persistence.Postgres.Repositories;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Persistence.Tests;
|
||||
|
||||
[Collection(AirGapPostgresCollection.Name)]
|
||||
public sealed class PostgresAirGapStateStoreTests : IAsyncLifetime
|
||||
{
|
||||
private readonly AirGapPostgresFixture _fixture;
|
||||
private readonly PostgresAirGapStateStore _store;
|
||||
private readonly AirGapDataSource _dataSource;
|
||||
private readonly string _tenantId = "tenant-" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
public PostgresAirGapStateStoreTests(AirGapPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
var options = Options.Create(new PostgresOptions
|
||||
{
|
||||
ConnectionString = fixture.ConnectionString,
|
||||
SchemaName = AirGapDataSource.DefaultSchemaName,
|
||||
AutoMigrate = false
|
||||
});
|
||||
|
||||
_dataSource = new AirGapDataSource(options, NullLogger<AirGapDataSource>.Instance);
|
||||
_store = new PostgresAirGapStateStore(_dataSource, NullLogger<PostgresAirGapStateStore>.Instance);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsDefaultStateForNewTenant()
|
||||
{
|
||||
// Act
|
||||
var state = await _store.GetAsync(_tenantId);
|
||||
|
||||
// Assert
|
||||
state.Should().NotBeNull();
|
||||
state.TenantId.Should().Be(_tenantId);
|
||||
state.Sealed.Should().BeFalse();
|
||||
state.PolicyHash.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SetAndGet_RoundTripsState()
|
||||
{
|
||||
// Arrange
|
||||
var timeAnchor = new TimeAnchor(
|
||||
DateTimeOffset.UtcNow,
|
||||
"tsa.example.com",
|
||||
"RFC3161",
|
||||
"sha256:fingerprint123",
|
||||
"sha256:tokendigest456");
|
||||
|
||||
var state = new AirGapState
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
TenantId = _tenantId,
|
||||
Sealed = true,
|
||||
PolicyHash = "sha256:policy789",
|
||||
TimeAnchor = timeAnchor,
|
||||
LastTransitionAt = DateTimeOffset.UtcNow,
|
||||
StalenessBudget = new StalenessBudget(1800, 3600),
|
||||
DriftBaselineSeconds = 5,
|
||||
ContentBudgets = new Dictionary<string, StalenessBudget>
|
||||
{
|
||||
["advisories"] = new StalenessBudget(7200, 14400),
|
||||
["vex"] = new StalenessBudget(3600, 7200)
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await _store.SetAsync(state);
|
||||
var fetched = await _store.GetAsync(_tenantId);
|
||||
|
||||
// Assert
|
||||
fetched.Should().NotBeNull();
|
||||
fetched.Sealed.Should().BeTrue();
|
||||
fetched.PolicyHash.Should().Be("sha256:policy789");
|
||||
fetched.TimeAnchor.Source.Should().Be("tsa.example.com");
|
||||
fetched.TimeAnchor.Format.Should().Be("RFC3161");
|
||||
fetched.StalenessBudget.WarningSeconds.Should().Be(1800);
|
||||
fetched.StalenessBudget.BreachSeconds.Should().Be(3600);
|
||||
fetched.DriftBaselineSeconds.Should().Be(5);
|
||||
fetched.ContentBudgets.Should().HaveCount(2);
|
||||
fetched.ContentBudgets["advisories"].WarningSeconds.Should().Be(7200);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SetAsync_UpdatesExistingState()
|
||||
{
|
||||
// Arrange
|
||||
var state1 = new AirGapState
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
TenantId = _tenantId,
|
||||
Sealed = false,
|
||||
TimeAnchor = TimeAnchor.Unknown,
|
||||
StalenessBudget = StalenessBudget.Default
|
||||
};
|
||||
|
||||
var state2 = new AirGapState
|
||||
{
|
||||
Id = state1.Id,
|
||||
TenantId = _tenantId,
|
||||
Sealed = true,
|
||||
PolicyHash = "sha256:updated",
|
||||
TimeAnchor = new TimeAnchor(DateTimeOffset.UtcNow, "updated-source", "rfc3161", "", ""),
|
||||
LastTransitionAt = DateTimeOffset.UtcNow,
|
||||
StalenessBudget = new StalenessBudget(600, 1200)
|
||||
};
|
||||
|
||||
// Act
|
||||
await _store.SetAsync(state1);
|
||||
await _store.SetAsync(state2);
|
||||
var fetched = await _store.GetAsync(_tenantId);
|
||||
|
||||
// Assert
|
||||
fetched.Sealed.Should().BeTrue();
|
||||
fetched.PolicyHash.Should().Be("sha256:updated");
|
||||
fetched.TimeAnchor.Source.Should().Be("updated-source");
|
||||
fetched.StalenessBudget.WarningSeconds.Should().Be(600);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SetAsync_PersistsContentBudgets()
|
||||
{
|
||||
// Arrange
|
||||
var state = new AirGapState
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
TenantId = _tenantId,
|
||||
TimeAnchor = TimeAnchor.Unknown,
|
||||
StalenessBudget = StalenessBudget.Default,
|
||||
ContentBudgets = new Dictionary<string, StalenessBudget>
|
||||
{
|
||||
["advisories"] = new StalenessBudget(3600, 7200),
|
||||
["vex"] = new StalenessBudget(1800, 3600),
|
||||
["policy"] = new StalenessBudget(900, 1800)
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await _store.SetAsync(state);
|
||||
var fetched = await _store.GetAsync(_tenantId);
|
||||
|
||||
// Assert
|
||||
fetched.ContentBudgets.Should().HaveCount(3);
|
||||
fetched.ContentBudgets.Should().ContainKey("advisories");
|
||||
fetched.ContentBudgets.Should().ContainKey("vex");
|
||||
fetched.ContentBudgets.Should().ContainKey("policy");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.AirGap.Persistence.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.AirGap.Persistence\StellaOps.AirGap.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.AirGap.Controller\StellaOps.AirGap.Controller.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Config;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
public class AirGapOptionsValidatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FailsWhenTenantMissing()
|
||||
{
|
||||
var opts = new AirGapOptions { TenantId = "" };
|
||||
var validator = new AirGapOptionsValidator();
|
||||
var result = validator.Validate(null, opts);
|
||||
Assert.True(result.Failed);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FailsWhenWarningExceedsBreach()
|
||||
{
|
||||
var opts = new AirGapOptions { TenantId = "t", Staleness = new StalenessOptions { WarningSeconds = 20, BreachSeconds = 10 } };
|
||||
var validator = new AirGapOptionsValidator();
|
||||
var result = validator.Validate(null, opts);
|
||||
Assert.True(result.Failed);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SucceedsForValidOptions()
|
||||
{
|
||||
var opts = new AirGapOptions { TenantId = "t", Staleness = new StalenessOptions { WarningSeconds = 10, BreachSeconds = 20 } };
|
||||
var validator = new AirGapOptionsValidator();
|
||||
var result = validator.Validate(null, opts);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,100 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Rfc3161Verifier with real SignedCms verification.
|
||||
/// Per AIRGAP-TIME-57-001: Trusted time-anchor service.
|
||||
/// </summary>
|
||||
public class Rfc3161VerifierTests
|
||||
{
|
||||
private readonly Rfc3161Verifier _verifier = new();
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenTrustRootsEmpty()
|
||||
{
|
||||
var token = new byte[] { 0x01, 0x02, 0x03 };
|
||||
|
||||
var result = _verifier.Verify(token, Array.Empty<TimeTrustRoot>(), out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("rfc3161-trust-roots-required", result.Reason);
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenTokenEmpty()
|
||||
{
|
||||
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
|
||||
|
||||
var result = _verifier.Verify(ReadOnlySpan<byte>.Empty, trust, out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("rfc3161-token-empty", result.Reason);
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenInvalidAsn1Structure()
|
||||
{
|
||||
var token = new byte[] { 0x01, 0x02, 0x03 }; // Invalid ASN.1
|
||||
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
|
||||
|
||||
var result = _verifier.Verify(token, trust, out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("rfc3161-", result.Reason);
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ProducesTokenDigest()
|
||||
{
|
||||
var token = new byte[] { 0x30, 0x00 }; // Empty SEQUENCE (minimal valid ASN.1)
|
||||
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
|
||||
|
||||
var result = _verifier.Verify(token, trust, out _);
|
||||
|
||||
// Should fail on CMS decode but attempt was made
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("rfc3161-", result.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_HandlesExceptionsGracefully()
|
||||
{
|
||||
// Create bytes that might cause internal exceptions
|
||||
var token = new byte[256];
|
||||
new Random(42).NextBytes(token);
|
||||
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
|
||||
|
||||
var result = _verifier.Verify(token, trust, out var anchor);
|
||||
|
||||
// Should not throw, should return failure result
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("rfc3161-", result.Reason);
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReportsDecodeErrorForMalformedCms()
|
||||
{
|
||||
// Create something that looks like CMS but isn't valid
|
||||
var token = new byte[] { 0x30, 0x82, 0x00, 0x10, 0x06, 0x09 };
|
||||
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
|
||||
|
||||
var result = _verifier.Verify(token, trust, out _);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
// Should report either decode or error
|
||||
Assert.True(result.Reason?.Contains("rfc3161-") ?? false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for RoughtimeVerifier with real Ed25519 signature verification.
|
||||
/// Per AIRGAP-TIME-57-001: Trusted time-anchor service.
|
||||
/// </summary>
|
||||
public class RoughtimeVerifierTests
|
||||
{
|
||||
private readonly RoughtimeVerifier _verifier = new();
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenTrustRootsEmpty()
|
||||
{
|
||||
var token = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
|
||||
var result = _verifier.Verify(token, Array.Empty<TimeTrustRoot>(), out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("roughtime-trust-roots-required", result.Reason);
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenTokenEmpty()
|
||||
{
|
||||
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
|
||||
|
||||
var result = _verifier.Verify(ReadOnlySpan<byte>.Empty, trust, out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("roughtime-token-empty", result.Reason);
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenTokenTooShort()
|
||||
{
|
||||
var token = new byte[] { 0x01, 0x02, 0x03 };
|
||||
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
|
||||
|
||||
var result = _verifier.Verify(token, trust, out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("roughtime-message-too-short", result.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenInvalidTagCount()
|
||||
{
|
||||
// Create a minimal wire format with invalid tag count
|
||||
var token = new byte[8];
|
||||
// Set num_tags to 0 (invalid)
|
||||
BitConverter.TryWriteBytes(token.AsSpan(0, 4), (uint)0);
|
||||
|
||||
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
|
||||
|
||||
var result = _verifier.Verify(token, trust, out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("roughtime-invalid-tag-count", result.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenNonEd25519Algorithm()
|
||||
{
|
||||
// Create a minimal valid-looking wire format
|
||||
var token = CreateMinimalRoughtimeToken();
|
||||
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "rsa") }; // Wrong algorithm
|
||||
|
||||
var result = _verifier.Verify(token, trust, out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
// Should fail either on parsing or signature verification
|
||||
Assert.Contains("roughtime-", result.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenKeyLengthWrong()
|
||||
{
|
||||
var token = CreateMinimalRoughtimeToken();
|
||||
var trust = new[] { new TimeTrustRoot("root1", new byte[16], "ed25519") }; // Wrong key length
|
||||
|
||||
var result = _verifier.Verify(token, trust, out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("roughtime-", result.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ProducesTokenDigest()
|
||||
{
|
||||
var token = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
|
||||
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
|
||||
|
||||
var result = _verifier.Verify(token, trust, out _);
|
||||
|
||||
// Even on failure, we should get a deterministic result
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal Roughtime wire format token for testing parsing paths.
|
||||
/// Note: This will fail signature verification but tests the parsing logic.
|
||||
/// </summary>
|
||||
private static byte[] CreateMinimalRoughtimeToken()
|
||||
{
|
||||
// Roughtime wire format:
|
||||
// [num_tags:u32] [offsets:u32[n-1]] [tags:u32[n]] [values...]
|
||||
// We'll create 2 tags: SIG and SREP
|
||||
|
||||
const uint TagSig = 0x00474953; // "SIG\0"
|
||||
const uint TagSrep = 0x50455253; // "SREP"
|
||||
|
||||
var sigValue = new byte[64]; // Ed25519 signature
|
||||
var srepValue = CreateMinimalSrep();
|
||||
|
||||
// Header: num_tags=2, offset[0]=64 (sig length), tags=[SIG, SREP]
|
||||
var headerSize = 4 + 4 + 8; // num_tags + 1 offset + 2 tags = 16 bytes
|
||||
var token = new byte[headerSize + sigValue.Length + srepValue.Length];
|
||||
|
||||
BitConverter.TryWriteBytes(token.AsSpan(0, 4), (uint)2); // num_tags = 2
|
||||
BitConverter.TryWriteBytes(token.AsSpan(4, 4), (uint)64); // offset[0] = 64 (sig length)
|
||||
BitConverter.TryWriteBytes(token.AsSpan(8, 4), TagSig);
|
||||
BitConverter.TryWriteBytes(token.AsSpan(12, 4), TagSrep);
|
||||
sigValue.CopyTo(token.AsSpan(16));
|
||||
srepValue.CopyTo(token.AsSpan(16 + 64));
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
private static byte[] CreateMinimalSrep()
|
||||
{
|
||||
// SREP with MIDP tag containing 8-byte timestamp
|
||||
const uint TagMidp = 0x5044494D; // "MIDP"
|
||||
|
||||
// Header: num_tags=1, tags=[MIDP]
|
||||
var headerSize = 4 + 4; // num_tags + 1 tag = 8 bytes
|
||||
var srepValue = new byte[headerSize + 8]; // + 8 bytes for MIDP value
|
||||
|
||||
BitConverter.TryWriteBytes(srepValue.AsSpan(0, 4), (uint)1); // num_tags = 1
|
||||
BitConverter.TryWriteBytes(srepValue.AsSpan(4, 4), TagMidp);
|
||||
// MIDP value: microseconds since Unix epoch (example: 2025-01-01 00:00:00 UTC)
|
||||
BitConverter.TryWriteBytes(srepValue.AsSpan(8, 8), 1735689600000000L);
|
||||
|
||||
return srepValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using StellaOps.AirGap.Time.Stores;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
public class SealedStartupValidatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FailsWhenAnchorMissing()
|
||||
{
|
||||
var validator = Build(out var statusService);
|
||||
var result = await validator.ValidateAsync("t1", StalenessBudget.Default, default);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("time-anchor-missing", result.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FailsWhenBreach()
|
||||
{
|
||||
var validator = Build(out var statusService);
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "src", "fmt", "fp", "digest");
|
||||
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
|
||||
var now = DateTimeOffset.UnixEpoch.AddSeconds(25);
|
||||
var status = await statusService.GetStatusAsync("t1", now);
|
||||
var result = status.Staleness.IsBreach;
|
||||
Assert.True(result);
|
||||
var validation = await validator.ValidateAsync("t1", new StalenessBudget(10, 20), default);
|
||||
Assert.False(validation.IsValid);
|
||||
Assert.Equal("time-anchor-stale", validation.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SucceedsWhenFresh()
|
||||
{
|
||||
var validator = Build(out var statusService);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var anchor = new TimeAnchor(now, "src", "fmt", "fp", "digest");
|
||||
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
|
||||
var validation = await validator.ValidateAsync("t1", new StalenessBudget(10, 20), default);
|
||||
Assert.True(validation.IsValid);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FailsOnBudgetMismatch()
|
||||
{
|
||||
var validator = Build(out var statusService);
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UtcNow, "src", "fmt", "fp", "digest");
|
||||
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
|
||||
|
||||
var validation = await validator.ValidateAsync("t1", new StalenessBudget(5, 15), default);
|
||||
|
||||
Assert.False(validation.IsValid);
|
||||
Assert.Equal("time-anchor-budget-mismatch", validation.Reason);
|
||||
}
|
||||
|
||||
private static SealedStartupValidator Build(out TimeStatusService statusService)
|
||||
{
|
||||
var store = new InMemoryTimeAnchorStore();
|
||||
statusService = new TimeStatusService(store, new StalenessCalculator(), new TimeTelemetry(), Microsoft.Extensions.Options.Options.Create(new AirGapOptions()));
|
||||
return new SealedStartupValidator(statusService);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
public class StalenessCalculatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void UnknownWhenNoAnchor()
|
||||
{
|
||||
var calc = new StalenessCalculator();
|
||||
var result = calc.Evaluate(TimeAnchor.Unknown, StalenessBudget.Default, DateTimeOffset.UnixEpoch);
|
||||
Assert.False(result.IsWarning);
|
||||
Assert.False(result.IsBreach);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BreachWhenBeyondBudget()
|
||||
{
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "source", "fmt", "fp", "digest");
|
||||
var budget = new StalenessBudget(10, 20);
|
||||
var calc = new StalenessCalculator();
|
||||
|
||||
var result = calc.Evaluate(anchor, budget, DateTimeOffset.UnixEpoch.AddSeconds(25));
|
||||
|
||||
Assert.True(result.IsBreach);
|
||||
Assert.True(result.IsWarning);
|
||||
Assert.Equal(25, result.AgeSeconds);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WarningWhenBetweenWarningAndBreach()
|
||||
{
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "source", "fmt", "fp", "digest");
|
||||
var budget = new StalenessBudget(10, 20);
|
||||
var calc = new StalenessCalculator();
|
||||
|
||||
var result = calc.Evaluate(anchor, budget, DateTimeOffset.UnixEpoch.AddSeconds(15));
|
||||
|
||||
Assert.True(result.IsWarning);
|
||||
Assert.False(result.IsBreach);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
public class TimeAnchorLoaderTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RejectsInvalidHex()
|
||||
{
|
||||
var loader = Build();
|
||||
var trust = new[] { new TimeTrustRoot("k1", new byte[32], "ed25519") };
|
||||
var result = loader.TryLoadHex("not-hex", TimeTokenFormat.Roughtime, trust, out _);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("token-hex-invalid", result.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void LoadsHexToken()
|
||||
{
|
||||
var loader = Build();
|
||||
var hex = "01020304";
|
||||
var trust = new[] { new TimeTrustRoot("k1", new byte[32], "ed25519") };
|
||||
var result = loader.TryLoadHex(hex, TimeTokenFormat.Roughtime, trust, out var anchor);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("Roughtime", anchor.Format);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RejectsIncompatibleTrustRoots()
|
||||
{
|
||||
var loader = Build();
|
||||
var hex = "010203";
|
||||
var rsaKey = new byte[128];
|
||||
var trust = new[] { new TimeTrustRoot("k1", rsaKey, "rsa") };
|
||||
|
||||
var result = loader.TryLoadHex(hex, TimeTokenFormat.Roughtime, trust, out _);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("trust-roots-incompatible-format", result.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RejectsWhenTrustRootsMissing()
|
||||
{
|
||||
var loader = Build();
|
||||
var result = loader.TryLoadHex("010203", TimeTokenFormat.Roughtime, Array.Empty<TimeTrustRoot>(), out _);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("trust-roots-required", result.Reason);
|
||||
}
|
||||
|
||||
private static TimeAnchorLoader Build()
|
||||
{
|
||||
var options = Options.Create(new AirGapOptions { AllowUntrustedAnchors = false });
|
||||
return new TimeAnchorLoader(new TimeVerificationService(), new TimeTokenParser(), options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using StellaOps.AirGap.Time.Stores;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for TimeAnchorPolicyService.
|
||||
/// Per AIRGAP-TIME-57-001: Time-anchor policy enforcement.
|
||||
/// </summary>
|
||||
public class TimeAnchorPolicyServiceTests
|
||||
{
|
||||
private readonly TimeProvider _fixedTimeProvider;
|
||||
private readonly InMemoryTimeAnchorStore _store;
|
||||
private readonly StalenessCalculator _calculator;
|
||||
private readonly TimeTelemetry _telemetry;
|
||||
private readonly TimeStatusService _statusService;
|
||||
private readonly AirGapOptions _airGapOptions;
|
||||
|
||||
public TimeAnchorPolicyServiceTests()
|
||||
{
|
||||
_fixedTimeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
_store = new InMemoryTimeAnchorStore();
|
||||
_calculator = new StalenessCalculator();
|
||||
_telemetry = new TimeTelemetry();
|
||||
_airGapOptions = new AirGapOptions
|
||||
{
|
||||
Staleness = new StalenessOptions { WarningSeconds = 3600, BreachSeconds = 7200 },
|
||||
ContentBudgets = new Dictionary<string, StalenessOptions>()
|
||||
};
|
||||
_statusService = new TimeStatusService(_store, _calculator, _telemetry, Options.Create(_airGapOptions));
|
||||
}
|
||||
|
||||
private TimeAnchorPolicyService CreateService(TimeAnchorPolicyOptions? options = null)
|
||||
{
|
||||
return new TimeAnchorPolicyService(
|
||||
_statusService,
|
||||
Options.Create(options ?? new TimeAnchorPolicyOptions()),
|
||||
NullLogger<TimeAnchorPolicyService>.Instance,
|
||||
_fixedTimeProvider);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateTimeAnchorAsync_ReturnsFailure_WhenNoAnchor()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var result = await service.ValidateTimeAnchorAsync("tenant-1");
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorMissing, result.ErrorCode);
|
||||
Assert.NotNull(result.Remediation);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateTimeAnchorAsync_ReturnsSuccess_WhenAnchorValid()
|
||||
{
|
||||
var service = CreateService();
|
||||
var anchor = new TimeAnchor(
|
||||
_fixedTimeProvider.GetUtcNow().AddMinutes(-30),
|
||||
"test-source",
|
||||
"Roughtime",
|
||||
"fingerprint",
|
||||
"digest123");
|
||||
var budget = new StalenessBudget(3600, 7200);
|
||||
|
||||
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
|
||||
|
||||
var result = await service.ValidateTimeAnchorAsync("tenant-1");
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Null(result.ErrorCode);
|
||||
Assert.NotNull(result.Staleness);
|
||||
Assert.False(result.Staleness.IsBreach);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateTimeAnchorAsync_ReturnsWarning_WhenAnchorStale()
|
||||
{
|
||||
var service = CreateService();
|
||||
var anchor = new TimeAnchor(
|
||||
_fixedTimeProvider.GetUtcNow().AddSeconds(-5000), // Past warning threshold
|
||||
"test-source",
|
||||
"Roughtime",
|
||||
"fingerprint",
|
||||
"digest123");
|
||||
var budget = new StalenessBudget(3600, 7200);
|
||||
|
||||
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
|
||||
|
||||
var result = await service.ValidateTimeAnchorAsync("tenant-1");
|
||||
|
||||
Assert.True(result.Allowed); // Allowed but with warning
|
||||
Assert.NotNull(result.Staleness);
|
||||
Assert.True(result.Staleness.IsWarning);
|
||||
Assert.Contains("warning", result.Reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateTimeAnchorAsync_ReturnsFailure_WhenAnchorBreached()
|
||||
{
|
||||
var service = CreateService();
|
||||
var anchor = new TimeAnchor(
|
||||
_fixedTimeProvider.GetUtcNow().AddSeconds(-8000), // Past breach threshold
|
||||
"test-source",
|
||||
"Roughtime",
|
||||
"fingerprint",
|
||||
"digest123");
|
||||
var budget = new StalenessBudget(3600, 7200);
|
||||
|
||||
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
|
||||
|
||||
var result = await service.ValidateTimeAnchorAsync("tenant-1");
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorBreached, result.ErrorCode);
|
||||
Assert.NotNull(result.Staleness);
|
||||
Assert.True(result.Staleness.IsBreach);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EnforceBundleImportPolicyAsync_AllowsImport_WhenAnchorValid()
|
||||
{
|
||||
var service = CreateService();
|
||||
var anchor = new TimeAnchor(
|
||||
_fixedTimeProvider.GetUtcNow().AddMinutes(-30),
|
||||
"test-source",
|
||||
"Roughtime",
|
||||
"fingerprint",
|
||||
"digest123");
|
||||
var budget = new StalenessBudget(3600, 7200);
|
||||
|
||||
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
|
||||
|
||||
var result = await service.EnforceBundleImportPolicyAsync(
|
||||
"tenant-1",
|
||||
"bundle-123",
|
||||
_fixedTimeProvider.GetUtcNow().AddMinutes(-15));
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EnforceBundleImportPolicyAsync_BlocksImport_WhenDriftExceeded()
|
||||
{
|
||||
var options = new TimeAnchorPolicyOptions { MaxDriftSeconds = 3600 }; // 1 hour max
|
||||
var service = CreateService(options);
|
||||
var anchor = new TimeAnchor(
|
||||
_fixedTimeProvider.GetUtcNow().AddMinutes(-30),
|
||||
"test-source",
|
||||
"Roughtime",
|
||||
"fingerprint",
|
||||
"digest123");
|
||||
var budget = new StalenessBudget(86400, 172800); // Large budget
|
||||
|
||||
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
|
||||
|
||||
var bundleTimestamp = _fixedTimeProvider.GetUtcNow().AddDays(-2); // 2 days ago
|
||||
|
||||
var result = await service.EnforceBundleImportPolicyAsync(
|
||||
"tenant-1",
|
||||
"bundle-123",
|
||||
bundleTimestamp);
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(TimeAnchorPolicyErrorCodes.DriftExceeded, result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EnforceOperationPolicyAsync_BlocksStrictOperations_WhenNoAnchor()
|
||||
{
|
||||
var options = new TimeAnchorPolicyOptions
|
||||
{
|
||||
StrictOperations = new[] { "attestation.sign" }
|
||||
};
|
||||
var service = CreateService(options);
|
||||
|
||||
var result = await service.EnforceOperationPolicyAsync("tenant-1", "attestation.sign");
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorMissing, result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EnforceOperationPolicyAsync_AllowsNonStrictOperations_InNonStrictMode()
|
||||
{
|
||||
var options = new TimeAnchorPolicyOptions
|
||||
{
|
||||
StrictEnforcement = false,
|
||||
StrictOperations = new[] { "attestation.sign" }
|
||||
};
|
||||
var service = CreateService(options);
|
||||
|
||||
var result = await service.EnforceOperationPolicyAsync("tenant-1", "some.other.operation");
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CalculateDriftAsync_ReturnsNoDrift_WhenNoAnchor()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var result = await service.CalculateDriftAsync("tenant-1", _fixedTimeProvider.GetUtcNow());
|
||||
|
||||
Assert.False(result.HasAnchor);
|
||||
Assert.Equal(TimeSpan.Zero, result.Drift);
|
||||
Assert.Null(result.AnchorTime);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CalculateDriftAsync_ReturnsDrift_WhenAnchorExists()
|
||||
{
|
||||
var service = CreateService(new TimeAnchorPolicyOptions { MaxDriftSeconds = 3600 });
|
||||
var anchorTime = _fixedTimeProvider.GetUtcNow().AddMinutes(-30);
|
||||
var anchor = new TimeAnchor(anchorTime, "test", "Roughtime", "fp", "digest");
|
||||
var budget = new StalenessBudget(3600, 7200);
|
||||
|
||||
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
|
||||
|
||||
var targetTime = _fixedTimeProvider.GetUtcNow().AddMinutes(15);
|
||||
var result = await service.CalculateDriftAsync("tenant-1", targetTime);
|
||||
|
||||
Assert.True(result.HasAnchor);
|
||||
Assert.Equal(anchorTime, result.AnchorTime);
|
||||
Assert.Equal(45, (int)result.Drift.TotalMinutes); // 30 min + 15 min
|
||||
Assert.False(result.DriftExceedsThreshold);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CalculateDriftAsync_DetectsExcessiveDrift()
|
||||
{
|
||||
var service = CreateService(new TimeAnchorPolicyOptions { MaxDriftSeconds = 60 }); // 1 minute max
|
||||
var anchor = new TimeAnchor(
|
||||
_fixedTimeProvider.GetUtcNow(),
|
||||
"test",
|
||||
"Roughtime",
|
||||
"fp",
|
||||
"digest");
|
||||
var budget = new StalenessBudget(3600, 7200);
|
||||
|
||||
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
|
||||
|
||||
var targetTime = _fixedTimeProvider.GetUtcNow().AddMinutes(5); // 5 minutes drift
|
||||
var result = await service.CalculateDriftAsync("tenant-1", targetTime);
|
||||
|
||||
Assert.True(result.HasAnchor);
|
||||
Assert.True(result.DriftExceedsThreshold);
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
public class TimeStatusDtoTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SerializesDeterministically()
|
||||
{
|
||||
var status = new TimeStatus(
|
||||
new TimeAnchor(DateTimeOffset.Parse("2025-01-01T00:00:00Z"), "source", "fmt", "fp", "digest"),
|
||||
new StalenessEvaluation(42, 10, 20, true, false),
|
||||
new StalenessBudget(10, 20),
|
||||
new Dictionary<string, StalenessEvaluation>
|
||||
{
|
||||
{ "advisories", new StalenessEvaluation(42, 10, 20, true, false) }
|
||||
},
|
||||
DateTimeOffset.Parse("2025-01-02T00:00:00Z"));
|
||||
|
||||
var json = TimeStatusDto.FromStatus(status).ToJson();
|
||||
Assert.Contains("\"contentStaleness\":{\"advisories\":{", json);
|
||||
Assert.Contains("\"ageSeconds\":42", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using StellaOps.AirGap.Time.Stores;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
public class TimeStatusServiceTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReturnsUnknownWhenNoAnchor()
|
||||
{
|
||||
var svc = Build(out var telemetry);
|
||||
var status = await svc.GetStatusAsync("t1", DateTimeOffset.UnixEpoch);
|
||||
Assert.Equal(TimeAnchor.Unknown, status.Anchor);
|
||||
Assert.False(status.Staleness.IsWarning);
|
||||
Assert.Equal(0, telemetry.GetLatest("t1")?.AgeSeconds ?? 0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PersistsAnchorAndBudget()
|
||||
{
|
||||
var svc = Build(out var telemetry);
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "source", "fmt", "fp", "digest");
|
||||
var budget = new StalenessBudget(10, 20);
|
||||
|
||||
await svc.SetAnchorAsync("t1", anchor, budget);
|
||||
var status = await svc.GetStatusAsync("t1", DateTimeOffset.UnixEpoch.AddSeconds(15));
|
||||
|
||||
Assert.Equal(anchor, status.Anchor);
|
||||
Assert.True(status.Staleness.IsWarning);
|
||||
Assert.False(status.Staleness.IsBreach);
|
||||
Assert.Equal(15, status.Staleness.AgeSeconds);
|
||||
var snap = telemetry.GetLatest("t1");
|
||||
Assert.NotNull(snap);
|
||||
Assert.Equal(status.Staleness.AgeSeconds, snap!.AgeSeconds);
|
||||
Assert.True(snap.IsWarning);
|
||||
}
|
||||
|
||||
private static TimeStatusService Build(out TimeTelemetry telemetry)
|
||||
{
|
||||
telemetry = new TimeTelemetry();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new AirGapOptions());
|
||||
return new TimeStatusService(new InMemoryTimeAnchorStore(), new StalenessCalculator(), telemetry, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
public class TimeTelemetryTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Records_latest_snapshot_per_tenant()
|
||||
{
|
||||
var telemetry = new TimeTelemetry();
|
||||
var status = new TimeStatus(
|
||||
new TimeAnchor(DateTimeOffset.UnixEpoch, "src", "fmt", "fp", "digest"),
|
||||
new StalenessEvaluation(90, 60, 120, true, false),
|
||||
StalenessBudget.Default,
|
||||
new Dictionary<string, StalenessEvaluation>{{"advisories", new StalenessEvaluation(90,60,120,true,false)}},
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
telemetry.Record("t1", status);
|
||||
|
||||
var snap = telemetry.GetLatest("t1");
|
||||
Assert.NotNull(snap);
|
||||
Assert.Equal(90, snap!.AgeSeconds);
|
||||
Assert.True(snap.IsWarning);
|
||||
Assert.False(snap.IsBreach);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
public class TimeTokenParserTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EmptyTokenFails()
|
||||
{
|
||||
var parser = new TimeTokenParser();
|
||||
var result = parser.TryParse(Array.Empty<byte>(), TimeTokenFormat.Roughtime, out var anchor);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("token-empty", result.Reason);
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RoughtimeTokenProducesDigest()
|
||||
{
|
||||
var parser = new TimeTokenParser();
|
||||
var token = new byte[] { 0x01, 0x02, 0x03 };
|
||||
|
||||
var result = parser.TryParse(token, TimeTokenFormat.Roughtime, out var anchor);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("Roughtime", anchor.Format);
|
||||
Assert.Equal("roughtime-token", anchor.Source);
|
||||
Assert.Equal("structure-stubbed", result.Reason);
|
||||
Assert.Matches("^[0-9a-f]{64}$", anchor.TokenDigest);
|
||||
Assert.NotEqual(DateTimeOffset.UnixEpoch, anchor.AnchorTime); // deterministic derivation
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
public class TimeVerificationServiceTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FailsWithoutTrustRoots()
|
||||
{
|
||||
var svc = new TimeVerificationService();
|
||||
var result = svc.Verify(new byte[] { 0x01 }, TimeTokenFormat.Roughtime, Array.Empty<TimeTrustRoot>(), out _);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("trust-roots-required", result.Reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SucceedsForRoughtimeWithTrustRoot()
|
||||
{
|
||||
var svc = new TimeVerificationService();
|
||||
var trust = new[] { new TimeTrustRoot("k1", new byte[] { 0x01 }, "rsassa-pss-sha256") };
|
||||
var result = svc.Verify(new byte[] { 0x01, 0x02 }, TimeTokenFormat.Roughtime, trust, out var anchor);
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("Roughtime", anchor.Format);
|
||||
Assert.Equal("k1", anchor.SignatureFingerprint);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user