Refactor code structure and optimize performance across multiple modules
This commit is contained in:
@@ -48,17 +48,48 @@ Before working in this directory:
|
||||
|
||||
## Test Categories
|
||||
|
||||
When writing tests, use appropriate xUnit traits:
|
||||
Use the standardized test categories from `StellaOps.TestKit.TestCategories`:
|
||||
|
||||
```csharp
|
||||
[Trait("Category", "Unit")] // Fast, isolated unit tests
|
||||
[Trait("Category", "Integration")] // Tests requiring infrastructure
|
||||
[Trait("Category", "E2E")] // Full end-to-end workflows
|
||||
[Trait("Category", "AirGap")] // Must work without network
|
||||
[Trait("Category", "Interop")] // Third-party tool compatibility
|
||||
[Trait("Category", "Performance")] // Performance benchmarks
|
||||
[Trait("Category", "Chaos")] // Failure injection tests
|
||||
[Trait("Category", "Security")] // Security-focused tests
|
||||
using StellaOps.TestKit;
|
||||
|
||||
// PR-GATING TESTS (run on every push/PR)
|
||||
[Trait("Category", TestCategories.Unit)] // Fast, in-memory, no external dependencies
|
||||
[Trait("Category", TestCategories.Architecture)] // Module dependency rules, naming conventions
|
||||
[Trait("Category", TestCategories.Contract)] // API/WebService contract verification
|
||||
[Trait("Category", TestCategories.Integration)] // Testcontainers, PostgreSQL, Valkey
|
||||
[Trait("Category", TestCategories.Security)] // Cryptographic validation, vulnerability scanning
|
||||
[Trait("Category", TestCategories.Golden)] // Output comparison against known-good references
|
||||
|
||||
// SCHEDULED/ON-DEMAND TESTS
|
||||
[Trait("Category", TestCategories.Performance)] // Performance measurements, SLO enforcement
|
||||
[Trait("Category", TestCategories.Benchmark)] // BenchmarkDotNet measurements
|
||||
[Trait("Category", TestCategories.AirGap)] // Offline/air-gapped environment validation
|
||||
[Trait("Category", TestCategories.Chaos)] // Fault injection, failure recovery
|
||||
[Trait("Category", TestCategories.Determinism)] // Reproducibility, stable ordering, idempotency
|
||||
[Trait("Category", TestCategories.Resilience)] // Retry policies, circuit breakers, timeouts
|
||||
[Trait("Category", TestCategories.Observability)] // OpenTelemetry traces, metrics, logging
|
||||
|
||||
// OTHER CATEGORIES
|
||||
[Trait("Category", TestCategories.Property)] // FsCheck/generative testing for invariants
|
||||
[Trait("Category", TestCategories.Snapshot)] // Golden master regression testing
|
||||
[Trait("Category", TestCategories.Live)] // Require external services (Rekor, feeds)
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
Tests are discovered dynamically by `.gitea/workflows/test-matrix.yml` which runs all `*.Tests.csproj` files with Category filtering:
|
||||
- **PR-Gating:** Unit, Architecture, Contract, Integration, Security, Golden
|
||||
- **Scheduled:** Performance, Benchmark (daily)
|
||||
- **On-Demand:** AirGap, Chaos, Determinism, Resilience, Observability
|
||||
|
||||
### Validation
|
||||
|
||||
Run the validation script to ensure all tests have Category traits:
|
||||
```bash
|
||||
python devops/scripts/validate-test-traits.py # Report coverage
|
||||
python devops/scripts/validate-test-traits.py --fix # Add default Unit trait
|
||||
python devops/scripts/validate-test-traits.py --json # JSON output for CI
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
@@ -9,11 +9,13 @@ using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Blocks_when_allowlist_missing_for_sealed_state()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
@@ -37,7 +39,8 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
Assert.Contains("egress-allowlist-missing", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Passes_when_materials_present_and_anchor_fresh()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
@@ -59,7 +62,8 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
await service.StartAsync(CancellationToken.None); // should not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Blocks_when_anchor_is_stale()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
@@ -82,7 +86,8 @@ public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
Assert.Contains("time-anchor-stale", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Blocks_when_rotation_pending_without_dual_approval()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
@@ -4,6 +4,7 @@ using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class AirGapStateServiceTests
|
||||
@@ -17,7 +18,8 @@ public class AirGapStateServiceTests
|
||||
_service = new AirGapStateService(_store, _calculator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Seal_sets_state_and_computes_staleness()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
@@ -35,7 +37,8 @@ public class AirGapStateServiceTests
|
||||
Assert.Equal(120 - status.Staleness.AgeSeconds, status.Staleness.SecondsRemaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Unseal_clears_sealed_flag_and_updates_timestamp()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
@@ -49,7 +52,8 @@ public class AirGapStateServiceTests
|
||||
Assert.Equal(later, status.State.LastTransitionAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Seal_persists_drift_baseline_seconds()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
@@ -61,7 +65,8 @@ public class AirGapStateServiceTests
|
||||
Assert.Equal(300, state.DriftBaselineSeconds); // 5 minutes = 300 seconds
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Seal_creates_default_content_budgets_when_not_provided()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
@@ -76,7 +81,8 @@ public class AirGapStateServiceTests
|
||||
Assert.Equal(budget, state.ContentBudgets["advisories"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Seal_uses_provided_content_budgets()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
@@ -95,7 +101,8 @@ public class AirGapStateServiceTests
|
||||
Assert.Equal(budget, state.ContentBudgets["policy"]); // Falls back to default
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetStatus_returns_per_content_staleness()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
@@ -3,13 +3,15 @@ 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();
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Upsert_and_read_state_by_tenant()
|
||||
{
|
||||
var state = new AirGapState
|
||||
@@ -32,7 +34,8 @@ public class InMemoryAirGapStateStoreTests
|
||||
Assert.Equal(10, stored.StalenessBudget.WarningSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Enforces_singleton_per_tenant()
|
||||
{
|
||||
var first = new AirGapState { TenantId = "tenant-y", Sealed = true, PolicyHash = "h1" };
|
||||
@@ -46,7 +49,8 @@ public class InMemoryAirGapStateStoreTests
|
||||
Assert.False(stored.Sealed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Defaults_to_unknown_when_missing()
|
||||
{
|
||||
var stored = await _store.GetAsync("absent");
|
||||
@@ -54,7 +58,8 @@ public class InMemoryAirGapStateStoreTests
|
||||
Assert.Equal("absent", stored.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Parallel_upserts_keep_single_document()
|
||||
{
|
||||
var tasks = Enumerable.Range(0, 20).Select(i =>
|
||||
@@ -74,7 +79,8 @@ public class InMemoryAirGapStateStoreTests
|
||||
Assert.StartsWith("hash-", stored.PolicyHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[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();
|
||||
@@ -95,7 +101,8 @@ public class InMemoryAirGapStateStoreTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[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");
|
||||
@@ -116,7 +123,8 @@ public class InMemoryAirGapStateStoreTests
|
||||
Assert.Equal(budget.BreachSeconds, stored.StalenessBudget.BreachSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Multi_tenant_states_preserve_transition_times()
|
||||
{
|
||||
var tenants = new[] { "a", "b", "c" };
|
||||
|
||||
@@ -7,6 +7,7 @@ using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class ReplayVerificationServiceTests
|
||||
@@ -22,7 +23,8 @@ public class ReplayVerificationServiceTests
|
||||
_service = new ReplayVerificationService(_stateService, new ReplayVerifier());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Passes_full_recompute_when_hashes_match()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-12-02T01:00:00Z");
|
||||
@@ -46,7 +48,8 @@ public class ReplayVerificationServiceTests
|
||||
Assert.Equal("full-recompute-passed", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Detects_stale_manifest()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
@@ -67,7 +70,8 @@ public class ReplayVerificationServiceTests
|
||||
Assert.Equal("manifest-stale", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Policy_freeze_requires_matching_policy()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Planning;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Importer.Tests;
|
||||
|
||||
public class BundleImportPlannerTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ReturnsFailureWhenBundlePathMissing()
|
||||
{
|
||||
var planner = new BundleImportPlanner();
|
||||
@@ -15,7 +17,8 @@ public class BundleImportPlannerTests
|
||||
Assert.Equal("bundle-path-required", result.InitialState.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ReturnsFailureWhenTrustRootsMissing()
|
||||
{
|
||||
var planner = new BundleImportPlanner();
|
||||
@@ -25,7 +28,8 @@ public class BundleImportPlannerTests
|
||||
Assert.Equal("trust-roots-required", result.InitialState.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ReturnsDefaultPlanWhenInputsProvided()
|
||||
{
|
||||
var planner = new BundleImportPlanner();
|
||||
|
||||
@@ -2,11 +2,14 @@ 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
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FailsWhenUntrustedKey()
|
||||
{
|
||||
var verifier = new DsseVerifier();
|
||||
@@ -18,7 +21,8 @@ public class DsseVerifierTests
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void VerifiesRsaPssSignature()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
|
||||
@@ -6,11 +6,14 @@ 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
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenTufInvalid_ShouldFailAndQuarantine()
|
||||
{
|
||||
var quarantine = new CapturingQuarantineService();
|
||||
@@ -54,7 +57,8 @@ public sealed class ImportValidatorTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenAllChecksPass_ShouldSucceedAndRecordActivation()
|
||||
{
|
||||
var root = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\"}";
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using StellaOps.AirGap.Importer.Models;
|
||||
using StellaOps.AirGap.Importer.Repositories;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Importer.Tests;
|
||||
|
||||
public class InMemoryBundleRepositoriesTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CatalogUpsertOverwritesPerTenant()
|
||||
{
|
||||
var repo = new InMemoryBundleCatalogRepository();
|
||||
@@ -20,7 +22,8 @@ public class InMemoryBundleRepositoriesTests
|
||||
Assert.Equal("d2", list[0].Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CatalogIsTenantIsolated()
|
||||
{
|
||||
var repo = new InMemoryBundleCatalogRepository();
|
||||
@@ -32,7 +35,8 @@ public class InMemoryBundleRepositoriesTests
|
||||
Assert.Equal("d1", t1[0].Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ItemsOrderedByPath()
|
||||
{
|
||||
var repo = new InMemoryBundleItemRepository();
|
||||
@@ -46,7 +50,8 @@ public class InMemoryBundleRepositoriesTests
|
||||
Assert.Equal(new[] { "a.txt", "b.txt" }, list.Select(i => i.Path).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ItemsTenantIsolated()
|
||||
{
|
||||
var repo = new InMemoryBundleItemRepository();
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Importer.Tests;
|
||||
|
||||
public class MerkleRootCalculatorTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EmptySetProducesEmptyRoot()
|
||||
{
|
||||
var calc = new MerkleRootCalculator();
|
||||
@@ -12,7 +14,8 @@ public class MerkleRootCalculatorTests
|
||||
Assert.Equal(string.Empty, root);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DeterministicAcrossOrder()
|
||||
{
|
||||
var calc = new MerkleRootCalculator();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using StellaOps.AirGap.Importer.Telemetry;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Importer.Tests;
|
||||
|
||||
public sealed class OfflineKitMetricsTests : IDisposable
|
||||
@@ -32,7 +34,8 @@ public sealed class OfflineKitMetricsTests : IDisposable
|
||||
|
||||
public void Dispose() => _listener.Dispose();
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RecordImport_EmitsCounterWithLabels()
|
||||
{
|
||||
using var metrics = new OfflineKitMetrics();
|
||||
@@ -47,7 +50,8 @@ public sealed class OfflineKitMetricsTests : IDisposable
|
||||
m.HasTag(OfflineKitMetrics.TagNames.TenantId, "tenant-a"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RecordAttestationVerifyLatency_EmitsHistogramWithLabels()
|
||||
{
|
||||
using var metrics = new OfflineKitMetrics();
|
||||
@@ -62,7 +66,8 @@ public sealed class OfflineKitMetricsTests : IDisposable
|
||||
m.HasTag(OfflineKitMetrics.TagNames.Success, "true"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RecordRekorSuccess_EmitsCounterWithLabels()
|
||||
{
|
||||
using var metrics = new OfflineKitMetrics();
|
||||
@@ -76,7 +81,8 @@ public sealed class OfflineKitMetricsTests : IDisposable
|
||||
m.HasTag(OfflineKitMetrics.TagNames.Mode, "offline"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RecordRekorRetry_EmitsCounterWithLabels()
|
||||
{
|
||||
using var metrics = new OfflineKitMetrics();
|
||||
@@ -90,7 +96,8 @@ public sealed class OfflineKitMetricsTests : IDisposable
|
||||
m.HasTag(OfflineKitMetrics.TagNames.Reason, "stale_snapshot"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RecordRekorInclusionLatency_EmitsHistogramWithLabels()
|
||||
{
|
||||
using var metrics = new OfflineKitMetrics();
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Importer.Tests;
|
||||
|
||||
public class ReplayVerifierTests
|
||||
{
|
||||
private readonly ReplayVerifier _verifier = new();
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FullRecompute_succeeds_when_hashes_match_and_fresh()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-12-02T01:00:00Z");
|
||||
@@ -28,7 +30,8 @@ public class ReplayVerifierTests
|
||||
Assert.Equal("full-recompute-passed", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Detects_hash_drift()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
@@ -49,7 +52,8 @@ public class ReplayVerifierTests
|
||||
Assert.Equal("manifest-hash-drift", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void PolicyFreeze_requires_matching_policy_hash()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Importer.Tests;
|
||||
|
||||
public class RootRotationPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RequiresTwoApprovers()
|
||||
{
|
||||
var policy = new RootRotationPolicy();
|
||||
@@ -13,7 +15,8 @@ public class RootRotationPolicyTests
|
||||
Assert.Equal("rotation-dual-approval-required", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RejectsNoChange()
|
||||
{
|
||||
var policy = new RootRotationPolicy();
|
||||
@@ -25,7 +28,8 @@ public class RootRotationPolicyTests
|
||||
Assert.Equal("rotation-no-change", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AcceptsRotationWithDualApproval()
|
||||
{
|
||||
var policy = new RootRotationPolicy();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Reconciliation/Fixtures/**/*">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Importer.Tests;
|
||||
|
||||
public class TufMetadataValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RejectsInvalidJson()
|
||||
{
|
||||
var validator = new TufMetadataValidator();
|
||||
@@ -12,7 +14,8 @@ public class TufMetadataValidatorTests
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AcceptsConsistentSnapshotHash()
|
||||
{
|
||||
var validator = new TufMetadataValidator();
|
||||
@@ -26,7 +29,8 @@ public class TufMetadataValidatorTests
|
||||
Assert.Equal("tuf-metadata-valid", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DetectsHashMismatch()
|
||||
{
|
||||
var validator = new TufMetadataValidator();
|
||||
|
||||
@@ -2,11 +2,13 @@ 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
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FailsWhenTenantMissing()
|
||||
{
|
||||
var opts = new AirGapOptions { TenantId = "" };
|
||||
@@ -15,7 +17,8 @@ public class AirGapOptionsValidatorTests
|
||||
Assert.True(result.Failed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FailsWhenWarningExceedsBreach()
|
||||
{
|
||||
var opts = new AirGapOptions { TenantId = "t", Staleness = new StalenessOptions { WarningSeconds = 20, BreachSeconds = 10 } };
|
||||
@@ -24,7 +27,8 @@ public class AirGapOptionsValidatorTests
|
||||
Assert.True(result.Failed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SucceedsForValidOptions()
|
||||
{
|
||||
var opts = new AirGapOptions { TenantId = "t", Staleness = new StalenessOptions { WarningSeconds = 10, BreachSeconds = 20 } };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -11,7 +12,8 @@ public class Rfc3161VerifierTests
|
||||
{
|
||||
private readonly Rfc3161Verifier _verifier = new();
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenTrustRootsEmpty()
|
||||
{
|
||||
var token = new byte[] { 0x01, 0x02, 0x03 };
|
||||
@@ -23,7 +25,8 @@ public class Rfc3161VerifierTests
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenTokenEmpty()
|
||||
{
|
||||
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
|
||||
@@ -35,7 +38,8 @@ public class Rfc3161VerifierTests
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenInvalidAsn1Structure()
|
||||
{
|
||||
var token = new byte[] { 0x01, 0x02, 0x03 }; // Invalid ASN.1
|
||||
@@ -48,7 +52,8 @@ public class Rfc3161VerifierTests
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ProducesTokenDigest()
|
||||
{
|
||||
var token = new byte[] { 0x30, 0x00 }; // Empty SEQUENCE (minimal valid ASN.1)
|
||||
@@ -61,7 +66,8 @@ public class Rfc3161VerifierTests
|
||||
Assert.Contains("rfc3161-", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_HandlesExceptionsGracefully()
|
||||
{
|
||||
// Create bytes that might cause internal exceptions
|
||||
@@ -77,7 +83,8 @@ public class Rfc3161VerifierTests
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReportsDecodeErrorForMalformedCms()
|
||||
{
|
||||
// Create something that looks like CMS but isn't valid
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -11,7 +12,8 @@ public class RoughtimeVerifierTests
|
||||
{
|
||||
private readonly RoughtimeVerifier _verifier = new();
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenTrustRootsEmpty()
|
||||
{
|
||||
var token = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
@@ -23,7 +25,8 @@ public class RoughtimeVerifierTests
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenTokenEmpty()
|
||||
{
|
||||
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
|
||||
@@ -35,7 +38,8 @@ public class RoughtimeVerifierTests
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenTokenTooShort()
|
||||
{
|
||||
var token = new byte[] { 0x01, 0x02, 0x03 };
|
||||
@@ -47,7 +51,8 @@ public class RoughtimeVerifierTests
|
||||
Assert.Equal("roughtime-message-too-short", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenInvalidTagCount()
|
||||
{
|
||||
// Create a minimal wire format with invalid tag count
|
||||
@@ -63,7 +68,8 @@ public class RoughtimeVerifierTests
|
||||
Assert.Equal("roughtime-invalid-tag-count", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenNonEd25519Algorithm()
|
||||
{
|
||||
// Create a minimal valid-looking wire format
|
||||
@@ -77,7 +83,8 @@ public class RoughtimeVerifierTests
|
||||
Assert.Contains("roughtime-", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ReturnsFailure_WhenKeyLengthWrong()
|
||||
{
|
||||
var token = CreateMinimalRoughtimeToken();
|
||||
@@ -89,7 +96,8 @@ public class RoughtimeVerifierTests
|
||||
Assert.Contains("roughtime-", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_ProducesTokenDigest()
|
||||
{
|
||||
var token = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
|
||||
|
||||
@@ -2,11 +2,13 @@ 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
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FailsWhenAnchorMissing()
|
||||
{
|
||||
var validator = Build(out var statusService);
|
||||
@@ -15,7 +17,8 @@ public class SealedStartupValidatorTests
|
||||
Assert.Equal("time-anchor-missing", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FailsWhenBreach()
|
||||
{
|
||||
var validator = Build(out var statusService);
|
||||
@@ -30,7 +33,8 @@ public class SealedStartupValidatorTests
|
||||
Assert.Equal("time-anchor-stale", validation.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SucceedsWhenFresh()
|
||||
{
|
||||
var validator = Build(out var statusService);
|
||||
@@ -41,7 +45,8 @@ public class SealedStartupValidatorTests
|
||||
Assert.True(validation.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task FailsOnBudgetMismatch()
|
||||
{
|
||||
var validator = Build(out var statusService);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
public class StalenessCalculatorTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void UnknownWhenNoAnchor()
|
||||
{
|
||||
var calc = new StalenessCalculator();
|
||||
@@ -14,7 +16,8 @@ public class StalenessCalculatorTests
|
||||
Assert.False(result.IsBreach);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BreachWhenBeyondBudget()
|
||||
{
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "source", "fmt", "fp", "digest");
|
||||
@@ -28,7 +31,8 @@ public class StalenessCalculatorTests
|
||||
Assert.Equal(25, result.AgeSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WarningWhenBetweenWarningAndBreach()
|
||||
{
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "source", "fmt", "fp", "digest");
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -3,11 +3,13 @@ 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
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RejectsInvalidHex()
|
||||
{
|
||||
var loader = Build();
|
||||
@@ -17,7 +19,8 @@ public class TimeAnchorLoaderTests
|
||||
Assert.Equal("token-hex-invalid", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void LoadsHexToken()
|
||||
{
|
||||
var loader = Build();
|
||||
@@ -29,7 +32,8 @@ public class TimeAnchorLoaderTests
|
||||
Assert.Equal("Roughtime", anchor.Format);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RejectsIncompatibleTrustRoots()
|
||||
{
|
||||
var loader = Build();
|
||||
@@ -43,7 +47,8 @@ public class TimeAnchorLoaderTests
|
||||
Assert.Equal("trust-roots-incompatible-format", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RejectsWhenTrustRootsMissing()
|
||||
{
|
||||
var loader = Build();
|
||||
|
||||
@@ -4,6 +4,7 @@ using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using StellaOps.AirGap.Time.Stores;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -42,7 +43,8 @@ public class TimeAnchorPolicyServiceTests
|
||||
_fixedTimeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateTimeAnchorAsync_ReturnsFailure_WhenNoAnchor()
|
||||
{
|
||||
var service = CreateService();
|
||||
@@ -54,7 +56,8 @@ public class TimeAnchorPolicyServiceTests
|
||||
Assert.NotNull(result.Remediation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateTimeAnchorAsync_ReturnsSuccess_WhenAnchorValid()
|
||||
{
|
||||
var service = CreateService();
|
||||
@@ -76,7 +79,8 @@ public class TimeAnchorPolicyServiceTests
|
||||
Assert.False(result.Staleness.IsBreach);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateTimeAnchorAsync_ReturnsWarning_WhenAnchorStale()
|
||||
{
|
||||
var service = CreateService();
|
||||
@@ -98,7 +102,8 @@ public class TimeAnchorPolicyServiceTests
|
||||
Assert.Contains("warning", result.Reason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateTimeAnchorAsync_ReturnsFailure_WhenAnchorBreached()
|
||||
{
|
||||
var service = CreateService();
|
||||
@@ -120,7 +125,8 @@ public class TimeAnchorPolicyServiceTests
|
||||
Assert.True(result.Staleness.IsBreach);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EnforceBundleImportPolicyAsync_AllowsImport_WhenAnchorValid()
|
||||
{
|
||||
var service = CreateService();
|
||||
@@ -142,7 +148,8 @@ public class TimeAnchorPolicyServiceTests
|
||||
Assert.True(result.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EnforceBundleImportPolicyAsync_BlocksImport_WhenDriftExceeded()
|
||||
{
|
||||
var options = new TimeAnchorPolicyOptions { MaxDriftSeconds = 3600 }; // 1 hour max
|
||||
@@ -168,7 +175,8 @@ public class TimeAnchorPolicyServiceTests
|
||||
Assert.Equal(TimeAnchorPolicyErrorCodes.DriftExceeded, result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EnforceOperationPolicyAsync_BlocksStrictOperations_WhenNoAnchor()
|
||||
{
|
||||
var options = new TimeAnchorPolicyOptions
|
||||
@@ -183,7 +191,8 @@ public class TimeAnchorPolicyServiceTests
|
||||
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorMissing, result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EnforceOperationPolicyAsync_AllowsNonStrictOperations_InNonStrictMode()
|
||||
{
|
||||
var options = new TimeAnchorPolicyOptions
|
||||
@@ -198,7 +207,8 @@ public class TimeAnchorPolicyServiceTests
|
||||
Assert.True(result.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CalculateDriftAsync_ReturnsNoDrift_WhenNoAnchor()
|
||||
{
|
||||
var service = CreateService();
|
||||
@@ -210,7 +220,8 @@ public class TimeAnchorPolicyServiceTests
|
||||
Assert.Null(result.AnchorTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CalculateDriftAsync_ReturnsDrift_WhenAnchorExists()
|
||||
{
|
||||
var service = CreateService(new TimeAnchorPolicyOptions { MaxDriftSeconds = 3600 });
|
||||
@@ -229,7 +240,8 @@ public class TimeAnchorPolicyServiceTests
|
||||
Assert.False(result.DriftExceedsThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CalculateDriftAsync_DetectsExcessiveDrift()
|
||||
{
|
||||
var service = CreateService(new TimeAnchorPolicyOptions { MaxDriftSeconds = 60 }); // 1 minute max
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
public class TimeStatusDtoTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SerializesDeterministically()
|
||||
{
|
||||
var status = new TimeStatus(
|
||||
|
||||
@@ -2,11 +2,13 @@ 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
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReturnsUnknownWhenNoAnchor()
|
||||
{
|
||||
var svc = Build(out var telemetry);
|
||||
@@ -16,7 +18,8 @@ public class TimeStatusServiceTests
|
||||
Assert.Equal(0, telemetry.GetLatest("t1")?.AgeSeconds ?? 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PersistsAnchorAndBudget()
|
||||
{
|
||||
var svc = Build(out var telemetry);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
public class TimeTelemetryTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Records_latest_snapshot_per_tenant()
|
||||
{
|
||||
var telemetry = new TimeTelemetry();
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
public class TimeTokenParserTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EmptyTokenFails()
|
||||
{
|
||||
var parser = new TimeTokenParser();
|
||||
@@ -16,7 +18,8 @@ public class TimeTokenParserTests
|
||||
Assert.Equal(TimeAnchor.Unknown, anchor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RoughtimeTokenProducesDigest()
|
||||
{
|
||||
var parser = new TimeTokenParser();
|
||||
|
||||
@@ -2,11 +2,13 @@ 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
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FailsWithoutTrustRoots()
|
||||
{
|
||||
var svc = new TimeVerificationService();
|
||||
@@ -15,7 +17,8 @@ public class TimeVerificationServiceTests
|
||||
Assert.Equal("trust-roots-required", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SucceedsForRoughtimeWithTrustRoot()
|
||||
{
|
||||
var svc = new TimeVerificationService();
|
||||
|
||||
79
src/__Tests/CicdTemplates/cicd-templates/README.md
Normal file
79
src/__Tests/CicdTemplates/cicd-templates/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# CI/CD Template Tests
|
||||
|
||||
This directory contains validation tests for the StellaOps CI/CD signing templates.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all validation tests
|
||||
./validate-templates.sh
|
||||
|
||||
# Run with verbose output
|
||||
bash -x validate-templates.sh
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
| Test Suite | Description |
|
||||
|------------|-------------|
|
||||
| File Existence | Verifies all template files exist |
|
||||
| YAML Syntax | Validates YAML syntax using yq |
|
||||
| Workflow Structure | Checks required fields in workflows |
|
||||
| Documentation | Validates documentation content |
|
||||
| Cross-Platform | Ensures consistent patterns across platforms |
|
||||
| actionlint | GitHub Actions specific linting |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
For full test coverage, install:
|
||||
|
||||
```bash
|
||||
# yq - YAML processor
|
||||
brew install yq # macOS
|
||||
# or
|
||||
apt-get install yq # Debian/Ubuntu
|
||||
|
||||
# actionlint - GitHub Actions linter
|
||||
go install github.com/rhysd/actionlint/cmd/actionlint@latest
|
||||
# or
|
||||
brew install actionlint
|
||||
```
|
||||
|
||||
## Templates Tested
|
||||
|
||||
### GitHub Actions
|
||||
- `stellaops-sign.yml` - Reusable signing workflow
|
||||
- `stellaops-verify.yml` - Reusable verification workflow
|
||||
- Example workflows for containers, SBOMs, verdicts
|
||||
|
||||
### GitLab CI
|
||||
- `.gitlab-ci-stellaops.yml` - Include-able templates
|
||||
- `example-pipeline.gitlab-ci.yml` - Full pipeline example
|
||||
|
||||
### Gitea
|
||||
- `release-keyless-sign.yml` - Release signing
|
||||
- `deploy-keyless-verify.yml` - Deploy verification
|
||||
|
||||
## Expected Output
|
||||
|
||||
```
|
||||
========================================
|
||||
CI/CD Template Validation Tests
|
||||
Sprint: SPRINT_20251226_004_BE
|
||||
========================================
|
||||
|
||||
Checking required tools
|
||||
✓ PASS: yq is installed
|
||||
✓ PASS: actionlint is installed
|
||||
|
||||
Testing GitHub Actions templates exist
|
||||
✓ PASS: .github/workflows/examples/stellaops-sign.yml exists
|
||||
...
|
||||
|
||||
Test Summary
|
||||
========================================
|
||||
Passed: 25
|
||||
Failed: 0
|
||||
|
||||
All tests passed!
|
||||
```
|
||||
424
src/__Tests/CicdTemplates/cicd-templates/validate-templates.sh
Normal file
424
src/__Tests/CicdTemplates/cicd-templates/validate-templates.sh
Normal file
@@ -0,0 +1,424 @@
|
||||
#!/bin/bash
|
||||
# CI/CD Template Validation Tests
|
||||
# Sprint: SPRINT_20251226_004_BE_cicd_signing_templates
|
||||
# Tasks: 0020-0024
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
PASS_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
log_pass() {
|
||||
echo -e "${GREEN}✓ PASS${NC}: $1"
|
||||
((PASS_COUNT++))
|
||||
}
|
||||
|
||||
log_fail() {
|
||||
echo -e "${RED}✗ FAIL${NC}: $1"
|
||||
((FAIL_COUNT++))
|
||||
}
|
||||
|
||||
log_skip() {
|
||||
echo -e "${YELLOW}○ SKIP${NC}: $1"
|
||||
}
|
||||
|
||||
log_section() {
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "$1"
|
||||
echo "========================================"
|
||||
}
|
||||
|
||||
# Check for required tools
|
||||
check_tools() {
|
||||
log_section "Checking required tools"
|
||||
|
||||
if command -v yq &> /dev/null; then
|
||||
log_pass "yq is installed"
|
||||
else
|
||||
log_skip "yq not installed - YAML structure tests will be skipped"
|
||||
fi
|
||||
|
||||
if command -v actionlint &> /dev/null; then
|
||||
log_pass "actionlint is installed"
|
||||
else
|
||||
log_skip "actionlint not installed - GitHub Actions lint tests will be skipped"
|
||||
fi
|
||||
}
|
||||
|
||||
# Test: GitHub Actions templates exist
|
||||
test_github_templates_exist() {
|
||||
log_section "Testing GitHub Actions templates exist"
|
||||
|
||||
local templates=(
|
||||
".github/workflows/examples/stellaops-sign.yml"
|
||||
".github/workflows/examples/stellaops-verify.yml"
|
||||
".github/workflows/examples/example-container-sign.yml"
|
||||
".github/workflows/examples/example-sbom-sign.yml"
|
||||
".github/workflows/examples/example-verdict-sign.yml"
|
||||
".github/workflows/examples/example-verification-gate.yml"
|
||||
)
|
||||
|
||||
for template in "${templates[@]}"; do
|
||||
if [[ -f "$ROOT_DIR/$template" ]]; then
|
||||
log_pass "$template exists"
|
||||
else
|
||||
log_fail "$template not found"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Test: GitLab CI templates exist
|
||||
test_gitlab_templates_exist() {
|
||||
log_section "Testing GitLab CI templates exist"
|
||||
|
||||
local templates=(
|
||||
"deploy/gitlab/examples/.gitlab-ci-stellaops.yml"
|
||||
"deploy/gitlab/examples/example-pipeline.gitlab-ci.yml"
|
||||
"deploy/gitlab/README.md"
|
||||
)
|
||||
|
||||
for template in "${templates[@]}"; do
|
||||
if [[ -f "$ROOT_DIR/$template" ]]; then
|
||||
log_pass "$template exists"
|
||||
else
|
||||
log_fail "$template not found"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Test: Gitea workflows exist
|
||||
test_gitea_workflows_exist() {
|
||||
log_section "Testing Gitea workflows exist"
|
||||
|
||||
local workflows=(
|
||||
".gitea/workflows/release-keyless-sign.yml"
|
||||
".gitea/workflows/deploy-keyless-verify.yml"
|
||||
)
|
||||
|
||||
for workflow in "${workflows[@]}"; do
|
||||
if [[ -f "$ROOT_DIR/$workflow" ]]; then
|
||||
log_pass "$workflow exists"
|
||||
else
|
||||
log_fail "$workflow not found"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Test: Documentation exists
|
||||
test_documentation_exists() {
|
||||
log_section "Testing documentation exists"
|
||||
|
||||
local docs=(
|
||||
"docs/guides/identity-constraints.md"
|
||||
"docs/guides/keyless-signing-troubleshooting.md"
|
||||
"docs/guides/keyless-signing-quickstart.md"
|
||||
)
|
||||
|
||||
for doc in "${docs[@]}"; do
|
||||
if [[ -f "$ROOT_DIR/$doc" ]]; then
|
||||
log_pass "$doc exists"
|
||||
else
|
||||
log_fail "$doc not found"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Test: YAML syntax validation
|
||||
test_yaml_syntax() {
|
||||
log_section "Testing YAML syntax"
|
||||
|
||||
if ! command -v yq &> /dev/null; then
|
||||
log_skip "yq not available - skipping YAML syntax tests"
|
||||
return
|
||||
fi
|
||||
|
||||
local yaml_files=(
|
||||
".github/workflows/examples/stellaops-sign.yml"
|
||||
".github/workflows/examples/stellaops-verify.yml"
|
||||
".github/workflows/examples/example-container-sign.yml"
|
||||
"deploy/gitlab/examples/.gitlab-ci-stellaops.yml"
|
||||
".gitea/workflows/release-keyless-sign.yml"
|
||||
)
|
||||
|
||||
for yaml_file in "${yaml_files[@]}"; do
|
||||
local full_path="$ROOT_DIR/$yaml_file"
|
||||
if [[ -f "$full_path" ]]; then
|
||||
if yq eval '.' "$full_path" > /dev/null 2>&1; then
|
||||
log_pass "$yaml_file has valid YAML syntax"
|
||||
else
|
||||
log_fail "$yaml_file has invalid YAML syntax"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Test: GitHub Actions workflow structure
|
||||
test_github_workflow_structure() {
|
||||
log_section "Testing GitHub Actions workflow structure"
|
||||
|
||||
if ! command -v yq &> /dev/null; then
|
||||
log_skip "yq not available - skipping structure tests"
|
||||
return
|
||||
fi
|
||||
|
||||
local sign_workflow="$ROOT_DIR/.github/workflows/examples/stellaops-sign.yml"
|
||||
|
||||
if [[ -f "$sign_workflow" ]]; then
|
||||
# Check for required fields
|
||||
if yq eval '.on.workflow_call' "$sign_workflow" | grep -q "inputs"; then
|
||||
log_pass "stellaops-sign.yml has workflow_call inputs"
|
||||
else
|
||||
log_fail "stellaops-sign.yml missing workflow_call inputs"
|
||||
fi
|
||||
|
||||
if yq eval '.jobs.sign.permissions' "$sign_workflow" | grep -q "id-token"; then
|
||||
log_pass "stellaops-sign.yml has id-token permission"
|
||||
else
|
||||
log_fail "stellaops-sign.yml missing id-token permission"
|
||||
fi
|
||||
|
||||
if yq eval '.jobs.sign.outputs' "$sign_workflow" > /dev/null 2>&1; then
|
||||
log_pass "stellaops-sign.yml has job outputs"
|
||||
else
|
||||
log_fail "stellaops-sign.yml missing job outputs"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Test: GitLab CI template structure
|
||||
test_gitlab_template_structure() {
|
||||
log_section "Testing GitLab CI template structure"
|
||||
|
||||
if ! command -v yq &> /dev/null; then
|
||||
log_skip "yq not available - skipping structure tests"
|
||||
return
|
||||
fi
|
||||
|
||||
local gitlab_template="$ROOT_DIR/deploy/gitlab/examples/.gitlab-ci-stellaops.yml"
|
||||
|
||||
if [[ -f "$gitlab_template" ]]; then
|
||||
# Check for hidden job templates
|
||||
if grep -q "\.stellaops-sign:" "$gitlab_template"; then
|
||||
log_pass "GitLab template has .stellaops-sign hidden job"
|
||||
else
|
||||
log_fail "GitLab template missing .stellaops-sign hidden job"
|
||||
fi
|
||||
|
||||
if grep -q "\.stellaops-verify:" "$gitlab_template"; then
|
||||
log_pass "GitLab template has .stellaops-verify hidden job"
|
||||
else
|
||||
log_fail "GitLab template missing .stellaops-verify hidden job"
|
||||
fi
|
||||
|
||||
if grep -q "id_tokens:" "$gitlab_template"; then
|
||||
log_pass "GitLab template has id_tokens configuration"
|
||||
else
|
||||
log_fail "GitLab template missing id_tokens configuration"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Test: Identity constraint documentation content
|
||||
test_identity_docs_content() {
|
||||
log_section "Testing identity constraint documentation content"
|
||||
|
||||
local doc="$ROOT_DIR/docs/guides/identity-constraints.md"
|
||||
|
||||
if [[ -f "$doc" ]]; then
|
||||
if grep -q "GitHub Actions" "$doc"; then
|
||||
log_pass "Identity docs cover GitHub Actions"
|
||||
else
|
||||
log_fail "Identity docs missing GitHub Actions coverage"
|
||||
fi
|
||||
|
||||
if grep -q "GitLab" "$doc"; then
|
||||
log_pass "Identity docs cover GitLab CI"
|
||||
else
|
||||
log_fail "Identity docs missing GitLab CI coverage"
|
||||
fi
|
||||
|
||||
if grep -q "certificate-identity" "$doc"; then
|
||||
log_pass "Identity docs explain certificate-identity"
|
||||
else
|
||||
log_fail "Identity docs missing certificate-identity explanation"
|
||||
fi
|
||||
|
||||
if grep -q "certificate-oidc-issuer" "$doc"; then
|
||||
log_pass "Identity docs explain certificate-oidc-issuer"
|
||||
else
|
||||
log_fail "Identity docs missing certificate-oidc-issuer explanation"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Test: Troubleshooting guide content
|
||||
test_troubleshooting_content() {
|
||||
log_section "Testing troubleshooting guide content"
|
||||
|
||||
local doc="$ROOT_DIR/docs/guides/keyless-signing-troubleshooting.md"
|
||||
|
||||
if [[ -f "$doc" ]]; then
|
||||
if grep -q "OIDC" "$doc"; then
|
||||
log_pass "Troubleshooting covers OIDC issues"
|
||||
else
|
||||
log_fail "Troubleshooting missing OIDC coverage"
|
||||
fi
|
||||
|
||||
if grep -q "Fulcio" "$doc"; then
|
||||
log_pass "Troubleshooting covers Fulcio issues"
|
||||
else
|
||||
log_fail "Troubleshooting missing Fulcio coverage"
|
||||
fi
|
||||
|
||||
if grep -q "Rekor" "$doc"; then
|
||||
log_pass "Troubleshooting covers Rekor issues"
|
||||
else
|
||||
log_fail "Troubleshooting missing Rekor coverage"
|
||||
fi
|
||||
|
||||
if grep -q "verification" "$doc"; then
|
||||
log_pass "Troubleshooting covers verification issues"
|
||||
else
|
||||
log_fail "Troubleshooting missing verification coverage"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Test: Quick-start guide content
|
||||
test_quickstart_content() {
|
||||
log_section "Testing quick-start guide content"
|
||||
|
||||
local doc="$ROOT_DIR/docs/guides/keyless-signing-quickstart.md"
|
||||
|
||||
if [[ -f "$doc" ]]; then
|
||||
if grep -q "GitHub Actions" "$doc"; then
|
||||
log_pass "Quick-start covers GitHub Actions"
|
||||
else
|
||||
log_fail "Quick-start missing GitHub Actions"
|
||||
fi
|
||||
|
||||
if grep -q "GitLab" "$doc"; then
|
||||
log_pass "Quick-start covers GitLab CI"
|
||||
else
|
||||
log_fail "Quick-start missing GitLab CI"
|
||||
fi
|
||||
|
||||
if grep -q "id-token: write" "$doc"; then
|
||||
log_pass "Quick-start shows id-token permission"
|
||||
else
|
||||
log_fail "Quick-start missing id-token permission example"
|
||||
fi
|
||||
|
||||
if grep -q "stella attest" "$doc"; then
|
||||
log_pass "Quick-start shows stella CLI usage"
|
||||
else
|
||||
log_fail "Quick-start missing stella CLI examples"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Test: GitHub Actions linting (if actionlint available)
|
||||
test_actionlint() {
|
||||
log_section "Testing GitHub Actions with actionlint"
|
||||
|
||||
if ! command -v actionlint &> /dev/null; then
|
||||
log_skip "actionlint not available - skipping lint tests"
|
||||
return
|
||||
fi
|
||||
|
||||
local workflows=(
|
||||
".github/workflows/examples/stellaops-sign.yml"
|
||||
".github/workflows/examples/stellaops-verify.yml"
|
||||
".github/workflows/examples/example-container-sign.yml"
|
||||
)
|
||||
|
||||
for workflow in "${workflows[@]}"; do
|
||||
local full_path="$ROOT_DIR/$workflow"
|
||||
if [[ -f "$full_path" ]]; then
|
||||
if actionlint "$full_path" 2>&1 | grep -q "error"; then
|
||||
log_fail "$workflow has actionlint errors"
|
||||
else
|
||||
log_pass "$workflow passes actionlint"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Test: Cross-platform verification pattern
|
||||
test_cross_platform_pattern() {
|
||||
log_section "Testing cross-platform verification patterns"
|
||||
|
||||
local github_verify="$ROOT_DIR/.github/workflows/examples/stellaops-verify.yml"
|
||||
local gitlab_template="$ROOT_DIR/deploy/gitlab/examples/.gitlab-ci-stellaops.yml"
|
||||
|
||||
# Check that both platforms use the same verification parameters
|
||||
if [[ -f "$github_verify" ]] && [[ -f "$gitlab_template" ]]; then
|
||||
if grep -q "certificate-identity" "$github_verify" && grep -q "CERTIFICATE_IDENTITY" "$gitlab_template"; then
|
||||
log_pass "Cross-platform: Both use certificate-identity pattern"
|
||||
else
|
||||
log_fail "Cross-platform: Missing consistent certificate-identity pattern"
|
||||
fi
|
||||
|
||||
if grep -q "certificate-oidc-issuer" "$github_verify" && grep -q "CERTIFICATE_OIDC_ISSUER" "$gitlab_template"; then
|
||||
log_pass "Cross-platform: Both use certificate-oidc-issuer pattern"
|
||||
else
|
||||
log_fail "Cross-platform: Missing consistent certificate-oidc-issuer pattern"
|
||||
fi
|
||||
|
||||
if grep -q "require-rekor" "$github_verify" && grep -q "REQUIRE_REKOR" "$gitlab_template"; then
|
||||
log_pass "Cross-platform: Both support Rekor requirement"
|
||||
else
|
||||
log_fail "Cross-platform: Missing consistent Rekor requirement support"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Run all tests
|
||||
main() {
|
||||
echo "============================================"
|
||||
echo "CI/CD Template Validation Tests"
|
||||
echo "Sprint: SPRINT_20251226_004_BE"
|
||||
echo "============================================"
|
||||
|
||||
check_tools
|
||||
test_github_templates_exist
|
||||
test_gitlab_templates_exist
|
||||
test_gitea_workflows_exist
|
||||
test_documentation_exists
|
||||
test_yaml_syntax
|
||||
test_github_workflow_structure
|
||||
test_gitlab_template_structure
|
||||
test_identity_docs_content
|
||||
test_troubleshooting_content
|
||||
test_quickstart_content
|
||||
test_actionlint
|
||||
test_cross_platform_pattern
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo "Test Summary"
|
||||
echo "============================================"
|
||||
echo -e "${GREEN}Passed: $PASS_COUNT${NC}"
|
||||
echo -e "${RED}Failed: $FAIL_COUNT${NC}"
|
||||
echo ""
|
||||
|
||||
if [[ $FAIL_COUNT -gt 0 ]]; then
|
||||
echo -e "${RED}Some tests failed!${NC}"
|
||||
exit 1
|
||||
else
|
||||
echo -e "${GREEN}All tests passed!${NC}"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -7,11 +7,13 @@ using StellaOps.Graph.Indexer.Ingestion.Advisory;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class AdvisoryLinksetProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_persists_batch_and_records_success()
|
||||
{
|
||||
var snapshot = CreateSnapshot();
|
||||
@@ -34,7 +36,8 @@ public sealed class AdvisoryLinksetProcessorTests
|
||||
metrics.LastRecord.EdgeCount.Should().Be(writer.LastBatch!.Edges.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_records_failure_when_writer_throws()
|
||||
{
|
||||
var snapshot = CreateSnapshot();
|
||||
|
||||
@@ -9,6 +9,7 @@ using StellaOps.Graph.Indexer.Ingestion.Advisory;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class AdvisoryLinksetTransformerTests
|
||||
@@ -33,7 +34,8 @@ public sealed class AdvisoryLinksetTransformerTests
|
||||
"AFFECTED_BY"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Transform_projects_advisory_nodes_and_affected_by_edges()
|
||||
{
|
||||
var snapshot = LoadSnapshot("concelier-linkset.json");
|
||||
|
||||
@@ -4,13 +4,15 @@ using FluentAssertions;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class FileSystemSnapshotFileWriterTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"graph-snapshots-{Guid.NewGuid():N}");
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteJsonAsync_writes_canonical_json()
|
||||
{
|
||||
var writer = new FileSystemSnapshotFileWriter(_root);
|
||||
@@ -26,7 +28,8 @@ public sealed class FileSystemSnapshotFileWriterTests : IDisposable
|
||||
content.Should().Be("{\"a\":\"value1\",\"b\":\"value2\"}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WriteJsonLinesAsync_writes_each_object_on_new_line()
|
||||
{
|
||||
var writer = new FileSystemSnapshotFileWriter(_root);
|
||||
|
||||
@@ -4,6 +4,7 @@ using FluentAssertions;
|
||||
using StellaOps.Graph.Indexer.Schema;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class GraphIdentityTests
|
||||
@@ -11,7 +12,8 @@ public sealed class GraphIdentityTests
|
||||
private static readonly string FixturesRoot =
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void NodeIds_are_stable()
|
||||
{
|
||||
var nodes = LoadArray("nodes.json");
|
||||
@@ -40,7 +42,8 @@ public sealed class GraphIdentityTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EdgeIds_are_stable()
|
||||
{
|
||||
var edges = LoadArray("edges.json");
|
||||
@@ -69,7 +72,8 @@ public sealed class GraphIdentityTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AttributeCoverage_matches_matrix()
|
||||
{
|
||||
var matrix = LoadObject("schema-matrix.json");
|
||||
|
||||
@@ -14,6 +14,7 @@ using StellaOps.Graph.Indexer.Ingestion.Vex;
|
||||
using StellaOps.Graph.Indexer.Schema;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class GraphSnapshotBuilderTests
|
||||
@@ -21,7 +22,8 @@ public sealed class GraphSnapshotBuilderTests
|
||||
private static readonly string FixturesRoot =
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Build_creates_manifest_and_adjacency_with_lineage()
|
||||
{
|
||||
var sbomSnapshot = Load<SbomSnapshot>("sbom-snapshot.json");
|
||||
|
||||
@@ -7,11 +7,13 @@ using StellaOps.Graph.Indexer.Ingestion.Policy;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class PolicyOverlayProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_persists_overlay_and_records_success_metrics()
|
||||
{
|
||||
var snapshot = CreateSnapshot();
|
||||
@@ -33,7 +35,8 @@ public sealed class PolicyOverlayProcessorTests
|
||||
metrics.LastRecord.EdgeCount.Should().Be(writer.LastBatch!.Edges.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_records_failure_when_writer_throws()
|
||||
{
|
||||
var snapshot = CreateSnapshot();
|
||||
|
||||
@@ -9,6 +9,7 @@ using StellaOps.Graph.Indexer.Ingestion.Policy;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class PolicyOverlayTransformerTests
|
||||
@@ -33,7 +34,8 @@ public sealed class PolicyOverlayTransformerTests
|
||||
"GOVERNS_WITH"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Transform_projects_policy_nodes_and_governs_with_edges()
|
||||
{
|
||||
var snapshot = LoadSnapshot("policy-overlay.json");
|
||||
|
||||
@@ -5,11 +5,13 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class SbomIngestProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_writes_batch_and_records_success_metrics()
|
||||
{
|
||||
var snapshot = CreateSnapshot();
|
||||
@@ -30,7 +32,8 @@ public sealed class SbomIngestProcessorTests
|
||||
snapshotExporter.LastBatch.Should().BeSameAs(writer.LastBatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_records_failure_when_writer_throws()
|
||||
{
|
||||
var snapshot = CreateSnapshot();
|
||||
|
||||
@@ -8,6 +8,8 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class SbomIngestServiceCollectionExtensionsTests : IDisposable
|
||||
@@ -23,7 +25,8 @@ public sealed class SbomIngestServiceCollectionExtensionsTests : IDisposable
|
||||
Directory.CreateDirectory(_tempDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task AddSbomIngestPipeline_exports_snapshots_to_configured_directory()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
@@ -42,7 +45,8 @@ public sealed class SbomIngestServiceCollectionExtensionsTests : IDisposable
|
||||
writer!.LastBatch.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task AddSbomIngestPipeline_uses_environment_variable_when_not_configured()
|
||||
{
|
||||
var previous = Environment.GetEnvironmentVariable("STELLAOPS_GRAPH_SNAPSHOT_DIR");
|
||||
|
||||
@@ -7,6 +7,7 @@ using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class SbomIngestTransformerTests
|
||||
@@ -36,7 +37,8 @@ public sealed class SbomIngestTransformerTests
|
||||
"BUILT_FROM"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Transform_produces_expected_nodes_and_edges()
|
||||
{
|
||||
var snapshot = LoadSnapshot("sbom-snapshot.json");
|
||||
@@ -92,7 +94,8 @@ public sealed class SbomIngestTransformerTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Transform_deduplicates_license_nodes_case_insensitive()
|
||||
{
|
||||
var baseCollectedAt = DateTimeOffset.Parse("2025-10-30T12:00:00Z");
|
||||
@@ -130,7 +133,8 @@ public sealed class SbomIngestTransformerTests
|
||||
canonicalKey["source_digest"]!.GetValue<string>().Should().Be("sha256:license001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Transform_emits_built_from_edge_with_provenance()
|
||||
{
|
||||
var snapshot = LoadSnapshot("sbom-snapshot.json");
|
||||
@@ -155,7 +159,8 @@ public sealed class SbomIngestTransformerTests
|
||||
canonicalKey.ContainsKey("child_artifact_digest").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Transform_normalizes_valid_from_to_utc()
|
||||
{
|
||||
var componentCollectedAt = new DateTimeOffset(2025, 11, 1, 15, 30, 45, TimeSpan.FromHours(2));
|
||||
|
||||
@@ -14,6 +14,7 @@ using StellaOps.Graph.Indexer.Ingestion.Sbom;
|
||||
using StellaOps.Graph.Indexer.Ingestion.Vex;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class SbomSnapshotExporterTests
|
||||
@@ -21,7 +22,8 @@ public sealed class SbomSnapshotExporterTests
|
||||
private static readonly string FixturesRoot =
|
||||
Path.Combine(AppContext.BaseDirectory, "Fixtures", "v1");
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ExportAsync_writes_manifest_adjacency_nodes_and_edges()
|
||||
{
|
||||
var sbomSnapshot = Load<SbomSnapshot>("sbom-snapshot.json");
|
||||
|
||||
@@ -9,6 +9,7 @@ using StellaOps.Graph.Indexer.Ingestion.Vex;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Graph.Indexer.Tests;
|
||||
|
||||
public sealed class VexOverlayTransformerTests
|
||||
@@ -33,7 +34,8 @@ public sealed class VexOverlayTransformerTests
|
||||
"VEX_EXEMPTS"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Transform_projects_vex_nodes_and_exempt_edges()
|
||||
{
|
||||
var snapshot = LoadSnapshot("excititor-vex.json");
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BinaryEvidenceDeterminismTests.cs
|
||||
// Sprint: SPRINT_20251226_014_BINIDX
|
||||
// Task: SCANINT-23 - Determinism tests for binary verdict reproducibility
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Testing.Determinism;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Integration.Determinism;
|
||||
|
||||
/// <summary>
|
||||
/// Determinism validation tests for binary vulnerability evidence.
|
||||
/// Ensures identical binary inputs produce identical verdicts across:
|
||||
/// - Binary identity extraction
|
||||
/// - Vulnerability match computation
|
||||
/// - Fix status determination
|
||||
/// - Proof segment generation
|
||||
/// - Multiple runs with frozen time
|
||||
/// - Parallel execution
|
||||
/// </summary>
|
||||
public class BinaryEvidenceDeterminismTests
|
||||
{
|
||||
#region Binary Identity Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void BinaryIdentity_WithIdenticalInput_ProducesDeterministicOutput()
|
||||
{
|
||||
// Arrange
|
||||
var binaryData = CreateSampleBinaryData();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
|
||||
// Act - Extract identity multiple times
|
||||
var identity1 = ExtractBinaryIdentity(binaryData, frozenTime);
|
||||
var identity2 = ExtractBinaryIdentity(binaryData, frozenTime);
|
||||
var identity3 = ExtractBinaryIdentity(binaryData, frozenTime);
|
||||
|
||||
// Assert - All outputs should be identical
|
||||
identity1.Should().Be(identity2);
|
||||
identity2.Should().Be(identity3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BinaryIdentity_BuildId_IsStable()
|
||||
{
|
||||
// Arrange
|
||||
var binaryData = CreateSampleBinaryData();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
|
||||
// Act
|
||||
var identity1 = ExtractBinaryIdentity(binaryData, frozenTime);
|
||||
var identity2 = ExtractBinaryIdentity(binaryData, frozenTime);
|
||||
|
||||
// Assert
|
||||
identity1.BuildId.Should().Be(identity2.BuildId);
|
||||
identity1.BuildId.Should().MatchRegex("^[0-9a-f]{40}$");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BinaryIdentity_BinaryKey_IsStable()
|
||||
{
|
||||
// Arrange
|
||||
var binaryData = CreateSampleBinaryData();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
|
||||
// Act
|
||||
var identity1 = ExtractBinaryIdentity(binaryData, frozenTime);
|
||||
var identity2 = ExtractBinaryIdentity(binaryData, frozenTime);
|
||||
|
||||
// Assert
|
||||
identity1.BinaryKey.Should().Be(identity2.BinaryKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BinaryIdentity_ParallelExtraction_ProducesDeterministicOutput()
|
||||
{
|
||||
// Arrange
|
||||
var binaryData = CreateSampleBinaryData();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
|
||||
// Act - Extract in parallel 20 times
|
||||
var tasks = Enumerable.Range(0, 20)
|
||||
.Select(_ => Task.Run(() => ExtractBinaryIdentity(binaryData, frozenTime)))
|
||||
.ToArray();
|
||||
|
||||
var identities = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert - All outputs should be identical
|
||||
identities.Should().AllBe(identities[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Vulnerability Match Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void VulnMatch_WithIdenticalBinary_ProducesDeterministicMatches()
|
||||
{
|
||||
// Arrange
|
||||
var identity = CreateSampleBinaryIdentity();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
|
||||
// Act - Look up matches multiple times
|
||||
var matches1 = LookupVulnerabilities(identity, frozenTime);
|
||||
var matches2 = LookupVulnerabilities(identity, frozenTime);
|
||||
var matches3 = LookupVulnerabilities(identity, frozenTime);
|
||||
|
||||
// Assert - All results should be identical
|
||||
var json1 = SerializeMatches(matches1);
|
||||
var json2 = SerializeMatches(matches2);
|
||||
var json3 = SerializeMatches(matches3);
|
||||
|
||||
json1.Should().Be(json2);
|
||||
json2.Should().Be(json3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VulnMatch_Ordering_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var identity = CreateSampleBinaryIdentityWithMultipleCves();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
|
||||
// Act
|
||||
var matches1 = LookupVulnerabilities(identity, frozenTime);
|
||||
var matches2 = LookupVulnerabilities(identity, frozenTime);
|
||||
|
||||
// Assert - CVEs should be in same order
|
||||
var cves1 = matches1.Select(m => m.CveId).ToList();
|
||||
var cves2 = matches2.Select(m => m.CveId).ToList();
|
||||
|
||||
cves1.Should().Equal(cves2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VulnMatch_Confidence_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var identity = CreateSampleBinaryIdentity();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
|
||||
// Act
|
||||
var matches1 = LookupVulnerabilities(identity, frozenTime);
|
||||
var matches2 = LookupVulnerabilities(identity, frozenTime);
|
||||
|
||||
// Assert - Confidence scores should be identical
|
||||
for (int i = 0; i < matches1.Length; i++)
|
||||
{
|
||||
matches1[i].Confidence.Should().Be(matches2[i].Confidence);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VulnMatch_CanonicalHash_IsStable()
|
||||
{
|
||||
// Arrange
|
||||
var identity = CreateSampleBinaryIdentity();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
|
||||
// Act
|
||||
var matches1 = LookupVulnerabilities(identity, frozenTime);
|
||||
var json1 = SerializeMatches(matches1);
|
||||
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json1));
|
||||
|
||||
var matches2 = LookupVulnerabilities(identity, frozenTime);
|
||||
var json2 = SerializeMatches(matches2);
|
||||
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json2));
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2);
|
||||
hash1.Should().MatchRegex("^[0-9a-f]{64}$");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fix Status Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void FixStatus_WithIdenticalInput_ProducesDeterministicOutput()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateFixStatusInput();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
|
||||
// Act
|
||||
var status1 = GetFixStatus(input, frozenTime);
|
||||
var status2 = GetFixStatus(input, frozenTime);
|
||||
var status3 = GetFixStatus(input, frozenTime);
|
||||
|
||||
// Assert
|
||||
SerializeFixStatus(status1).Should().Be(SerializeFixStatus(status2));
|
||||
SerializeFixStatus(status2).Should().Be(SerializeFixStatus(status3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixStatus_BackportDetection_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateBackportedCveInput();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
|
||||
// Act
|
||||
var status1 = GetFixStatus(input, frozenTime);
|
||||
var status2 = GetFixStatus(input, frozenTime);
|
||||
|
||||
// Assert - Both should detect as fixed
|
||||
status1.State.Should().Be("fixed");
|
||||
status2.State.Should().Be("fixed");
|
||||
status1.FixedVersion.Should().Be(status2.FixedVersion);
|
||||
status1.Confidence.Should().Be(status2.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixStatus_Method_IsConsistent()
|
||||
{
|
||||
// Arrange
|
||||
var input = CreateFixStatusInput();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
|
||||
// Act
|
||||
var status = GetFixStatus(input, frozenTime);
|
||||
|
||||
// Assert - Method should be one of known values
|
||||
status.Method.Should().BeOneOf("changelog", "patch_analysis", "advisory");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Proof Segment Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void ProofSegment_WithIdenticalEvidence_ProducesDeterministicOutput()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = CreateSampleBinaryEvidence();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
var deterministicId = GenerateDeterministicProofId(evidence, frozenTime);
|
||||
|
||||
// Act
|
||||
var proof1 = CreateBinaryProofSegment(evidence, frozenTime, deterministicId);
|
||||
var proof2 = CreateBinaryProofSegment(evidence, frozenTime, deterministicId);
|
||||
var proof3 = CreateBinaryProofSegment(evidence, frozenTime, deterministicId);
|
||||
|
||||
// Assert
|
||||
proof1.Should().Be(proof2);
|
||||
proof2.Should().Be(proof3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofSegment_CanonicalHash_IsStable()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = CreateSampleBinaryEvidence();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
var deterministicId = GenerateDeterministicProofId(evidence, frozenTime);
|
||||
|
||||
// Act
|
||||
var proof1 = CreateBinaryProofSegment(evidence, frozenTime, deterministicId);
|
||||
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(proof1));
|
||||
|
||||
var proof2 = CreateBinaryProofSegment(evidence, frozenTime, deterministicId);
|
||||
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(proof2));
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProofSegment_PredicateType_IsConsistent()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = CreateSampleBinaryEvidence();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
var deterministicId = GenerateDeterministicProofId(evidence, frozenTime);
|
||||
|
||||
// Act
|
||||
var proof = CreateBinaryProofSegment(evidence, frozenTime, deterministicId);
|
||||
|
||||
// Assert
|
||||
proof.Should().Contain("\"predicateType\"");
|
||||
proof.Should().Contain("https://stellaops.dev/predicates/binary-fingerprint-evidence@v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProofSegment_ParallelGeneration_ProducesDeterministicOutput()
|
||||
{
|
||||
// Arrange
|
||||
var evidence = CreateSampleBinaryEvidence();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
var deterministicId = GenerateDeterministicProofId(evidence, frozenTime);
|
||||
|
||||
// Act - Generate in parallel
|
||||
var tasks = Enumerable.Range(0, 20)
|
||||
.Select(_ => Task.Run(() => CreateBinaryProofSegment(evidence, frozenTime, deterministicId)))
|
||||
.ToArray();
|
||||
|
||||
var proofs = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert
|
||||
proofs.Should().AllBe(proofs[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region End-to-End Verdict Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void FullBinaryVerdict_WithIdenticalInput_ProducesDeterministicOutput()
|
||||
{
|
||||
// Arrange
|
||||
var scanInput = CreateSampleScanInput();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
|
||||
// Act - Process scan multiple times
|
||||
var verdict1 = ProcessBinaryScan(scanInput, frozenTime);
|
||||
var verdict2 = ProcessBinaryScan(scanInput, frozenTime);
|
||||
var verdict3 = ProcessBinaryScan(scanInput, frozenTime);
|
||||
|
||||
// Assert
|
||||
var json1 = SerializeVerdict(verdict1);
|
||||
var json2 = SerializeVerdict(verdict2);
|
||||
var json3 = SerializeVerdict(verdict3);
|
||||
|
||||
json1.Should().Be(json2);
|
||||
json2.Should().Be(json3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullBinaryVerdict_CanonicalHash_IsStable()
|
||||
{
|
||||
// Arrange
|
||||
var scanInput = CreateSampleScanInput();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
|
||||
// Act
|
||||
var verdict1 = ProcessBinaryScan(scanInput, frozenTime);
|
||||
var json1 = SerializeVerdict(verdict1);
|
||||
var hash1 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json1));
|
||||
|
||||
var verdict2 = ProcessBinaryScan(scanInput, frozenTime);
|
||||
var json2 = SerializeVerdict(verdict2);
|
||||
var hash2 = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(json2));
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullBinaryVerdict_DeterminismManifest_CanBeCreated()
|
||||
{
|
||||
// Arrange
|
||||
var scanInput = CreateSampleScanInput();
|
||||
var frozenTime = DateTimeOffset.Parse("2025-12-23T18:00:00Z");
|
||||
var verdict = ProcessBinaryScan(scanInput, frozenTime);
|
||||
var verdictBytes = Encoding.UTF8.GetBytes(SerializeVerdict(verdict));
|
||||
|
||||
var artifactInfo = new ArtifactInfo
|
||||
{
|
||||
Type = "binary-evidence",
|
||||
Name = "binary-vulnerability-verdict",
|
||||
Version = "1.0.0",
|
||||
Format = "BinaryEvidence JSON"
|
||||
};
|
||||
|
||||
var toolchain = new ToolchainInfo
|
||||
{
|
||||
Platform = ".NET 10.0",
|
||||
Components = new[]
|
||||
{
|
||||
new ComponentInfo { Name = "StellaOps.BinaryIndex", Version = "1.0.0" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var manifest = DeterminismManifestWriter.CreateManifest(
|
||||
verdictBytes,
|
||||
artifactInfo,
|
||||
toolchain);
|
||||
|
||||
// Assert
|
||||
manifest.SchemaVersion.Should().Be("1.0");
|
||||
manifest.Artifact.Format.Should().Be("BinaryEvidence JSON");
|
||||
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static byte[] CreateSampleBinaryData()
|
||||
{
|
||||
// Simulated ELF binary data with Build-ID
|
||||
var data = new byte[1024];
|
||||
var random = new Random(42); // Deterministic seed
|
||||
random.NextBytes(data);
|
||||
|
||||
// Add ELF magic header
|
||||
data[0] = 0x7f;
|
||||
data[1] = 0x45; // E
|
||||
data[2] = 0x4c; // L
|
||||
data[3] = 0x46; // F
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private static BinaryIdentityResult ExtractBinaryIdentity(byte[] data, DateTimeOffset timestamp)
|
||||
{
|
||||
// Compute deterministic Build-ID from data
|
||||
var buildId = ComputeDeterministicBuildId(data);
|
||||
var fileSha256 = CanonJson.Sha256Hex(data);
|
||||
|
||||
return new BinaryIdentityResult
|
||||
{
|
||||
Format = "elf",
|
||||
BuildId = buildId,
|
||||
FileSha256 = $"sha256:{fileSha256}",
|
||||
Architecture = "x86_64",
|
||||
BinaryKey = $"test-binary:{buildId[..8]}"
|
||||
};
|
||||
}
|
||||
|
||||
private static BinaryIdentityResult CreateSampleBinaryIdentity()
|
||||
{
|
||||
return new BinaryIdentityResult
|
||||
{
|
||||
Format = "elf",
|
||||
BuildId = "8d8f09a0d7e2c1b3a5f4e6d8c0b2a4e6f8d0c2b4",
|
||||
FileSha256 = "sha256:abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab",
|
||||
Architecture = "x86_64",
|
||||
BinaryKey = "openssl:1.1.1w-1"
|
||||
};
|
||||
}
|
||||
|
||||
private static BinaryIdentityResult CreateSampleBinaryIdentityWithMultipleCves()
|
||||
{
|
||||
return new BinaryIdentityResult
|
||||
{
|
||||
Format = "elf",
|
||||
BuildId = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
|
||||
FileSha256 = "sha256:1111222233334444555566667777888899990000aaaabbbbccccddddeeeeffff",
|
||||
Architecture = "x86_64",
|
||||
BinaryKey = "curl:7.74.0-1"
|
||||
};
|
||||
}
|
||||
|
||||
private static VulnMatch[] LookupVulnerabilities(BinaryIdentityResult identity, DateTimeOffset timestamp)
|
||||
{
|
||||
// Deterministic vulnerability lookup based on binary key
|
||||
var matches = new List<VulnMatch>();
|
||||
|
||||
if (identity.BinaryKey.Contains("openssl"))
|
||||
{
|
||||
matches.Add(new VulnMatch
|
||||
{
|
||||
CveId = "CVE-2023-5678",
|
||||
Method = "buildid_catalog",
|
||||
Confidence = 0.95m,
|
||||
VulnerablePurl = "pkg:deb/debian/openssl@1.1.1n-0+deb11u4"
|
||||
});
|
||||
}
|
||||
|
||||
if (identity.BinaryKey.Contains("curl"))
|
||||
{
|
||||
matches.Add(new VulnMatch
|
||||
{
|
||||
CveId = "CVE-2023-38545",
|
||||
Method = "buildid_catalog",
|
||||
Confidence = 0.98m,
|
||||
VulnerablePurl = "pkg:deb/debian/curl@7.74.0-1.3+deb11u5"
|
||||
});
|
||||
matches.Add(new VulnMatch
|
||||
{
|
||||
CveId = "CVE-2024-2398",
|
||||
Method = "buildid_catalog",
|
||||
Confidence = 0.96m,
|
||||
VulnerablePurl = "pkg:deb/debian/curl@7.74.0-1.3+deb11u6"
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by CVE ID for deterministic ordering
|
||||
return matches.OrderBy(m => m.CveId, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static FixStatusInput CreateFixStatusInput()
|
||||
{
|
||||
return new FixStatusInput
|
||||
{
|
||||
Distro = "debian",
|
||||
Release = "bookworm",
|
||||
SourcePkg = "openssl",
|
||||
CveId = "CVE-2023-5678"
|
||||
};
|
||||
}
|
||||
|
||||
private static FixStatusInput CreateBackportedCveInput()
|
||||
{
|
||||
return new FixStatusInput
|
||||
{
|
||||
Distro = "debian",
|
||||
Release = "bookworm",
|
||||
SourcePkg = "openssl",
|
||||
CveId = "CVE-2023-4807"
|
||||
};
|
||||
}
|
||||
|
||||
private static FixStatusResult GetFixStatus(FixStatusInput input, DateTimeOffset timestamp)
|
||||
{
|
||||
// Deterministic fix status based on input
|
||||
return new FixStatusResult
|
||||
{
|
||||
State = "fixed",
|
||||
FixedVersion = "1.1.1w-1",
|
||||
Method = "changelog",
|
||||
Confidence = 0.98m
|
||||
};
|
||||
}
|
||||
|
||||
private static BinaryEvidence CreateSampleBinaryEvidence()
|
||||
{
|
||||
return new BinaryEvidence
|
||||
{
|
||||
Identity = CreateSampleBinaryIdentity(),
|
||||
LayerDigest = "sha256:layer1abc123def456789012345678901234567890abcdef12345678901234",
|
||||
Matches = LookupVulnerabilities(CreateSampleBinaryIdentity(), DateTimeOffset.UtcNow)
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateDeterministicProofId(BinaryEvidence evidence, DateTimeOffset timestamp)
|
||||
{
|
||||
var seed = $"{evidence.Identity.BinaryKey}:{evidence.LayerDigest}:{timestamp:O}";
|
||||
var hash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(seed));
|
||||
return $"proof:{hash[..32]}";
|
||||
}
|
||||
|
||||
private static string CreateBinaryProofSegment(BinaryEvidence evidence, DateTimeOffset timestamp, string proofId)
|
||||
{
|
||||
var matchesJson = string.Join(",\n ", evidence.Matches.Select(m => $$"""
|
||||
{
|
||||
"cve_id": "{{m.CveId}}",
|
||||
"method": "{{m.Method}}",
|
||||
"confidence": {{m.Confidence.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)}},
|
||||
"vulnerable_purl": "{{m.VulnerablePurl}}"
|
||||
}
|
||||
"""));
|
||||
|
||||
return $$"""
|
||||
{
|
||||
"predicateType": "https://stellaops.dev/predicates/binary-fingerprint-evidence@v1",
|
||||
"proofId": "{{proofId}}",
|
||||
"createdAt": "{{timestamp:O}}",
|
||||
"binaryIdentity": {
|
||||
"format": "{{evidence.Identity.Format}}",
|
||||
"buildId": "{{evidence.Identity.BuildId}}",
|
||||
"fileSha256": "{{evidence.Identity.FileSha256}}",
|
||||
"architecture": "{{evidence.Identity.Architecture}}",
|
||||
"binaryKey": "{{evidence.Identity.BinaryKey}}"
|
||||
},
|
||||
"layerDigest": "{{evidence.LayerDigest}}",
|
||||
"matches": [
|
||||
{{matchesJson}}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private static ScanInput CreateSampleScanInput()
|
||||
{
|
||||
return new ScanInput
|
||||
{
|
||||
ImageDigest = "sha256:9f92a8c39f8d4f7bb1a60f2be650b3019b9a1bb50d2da839efa9bf2a278a0071",
|
||||
Distro = "debian",
|
||||
Release = "bookworm",
|
||||
Binaries = new[]
|
||||
{
|
||||
CreateSampleBinaryData()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static BinaryVerdict ProcessBinaryScan(ScanInput input, DateTimeOffset timestamp)
|
||||
{
|
||||
var binaries = new List<BinaryEvidence>();
|
||||
|
||||
foreach (var binaryData in input.Binaries)
|
||||
{
|
||||
var identity = ExtractBinaryIdentity(binaryData, timestamp);
|
||||
var matches = LookupVulnerabilities(identity, timestamp);
|
||||
|
||||
binaries.Add(new BinaryEvidence
|
||||
{
|
||||
Identity = identity,
|
||||
LayerDigest = "sha256:layer1",
|
||||
Matches = matches
|
||||
});
|
||||
}
|
||||
|
||||
return new BinaryVerdict
|
||||
{
|
||||
ScanId = GenerateScanId(input, timestamp),
|
||||
ImageDigest = input.ImageDigest,
|
||||
ScannedAt = timestamp,
|
||||
Binaries = binaries.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateScanId(ScanInput input, DateTimeOffset timestamp)
|
||||
{
|
||||
var seed = $"{input.ImageDigest}:{timestamp:O}";
|
||||
var hash = CanonJson.Sha256Hex(Encoding.UTF8.GetBytes(seed));
|
||||
return $"scan-{hash[..16]}";
|
||||
}
|
||||
|
||||
private static string ComputeDeterministicBuildId(byte[] data)
|
||||
{
|
||||
using var sha1 = SHA1.Create();
|
||||
var hash = sha1.ComputeHash(data);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string SerializeMatches(VulnMatch[] matches)
|
||||
{
|
||||
return JsonSerializer.Serialize(matches, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
});
|
||||
}
|
||||
|
||||
private static string SerializeFixStatus(FixStatusResult status)
|
||||
{
|
||||
return JsonSerializer.Serialize(status, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
});
|
||||
}
|
||||
|
||||
private static string SerializeVerdict(BinaryVerdict verdict)
|
||||
{
|
||||
return JsonSerializer.Serialize(verdict, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DTOs
|
||||
|
||||
private sealed record BinaryIdentityResult
|
||||
{
|
||||
public required string Format { get; init; }
|
||||
public required string BuildId { get; init; }
|
||||
public required string FileSha256 { get; init; }
|
||||
public required string Architecture { get; init; }
|
||||
public required string BinaryKey { get; init; }
|
||||
}
|
||||
|
||||
private sealed record VulnMatch
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required string Method { get; init; }
|
||||
public required decimal Confidence { get; init; }
|
||||
public required string VulnerablePurl { get; init; }
|
||||
}
|
||||
|
||||
private sealed record FixStatusInput
|
||||
{
|
||||
public required string Distro { get; init; }
|
||||
public required string Release { get; init; }
|
||||
public required string SourcePkg { get; init; }
|
||||
public required string CveId { get; init; }
|
||||
}
|
||||
|
||||
private sealed record FixStatusResult
|
||||
{
|
||||
public required string State { get; init; }
|
||||
public required string FixedVersion { get; init; }
|
||||
public required string Method { get; init; }
|
||||
public required decimal Confidence { get; init; }
|
||||
}
|
||||
|
||||
private sealed record BinaryEvidence
|
||||
{
|
||||
public required BinaryIdentityResult Identity { get; init; }
|
||||
public required string LayerDigest { get; init; }
|
||||
public required VulnMatch[] Matches { get; init; }
|
||||
}
|
||||
|
||||
private sealed record ScanInput
|
||||
{
|
||||
public required string ImageDigest { get; init; }
|
||||
public required string Distro { get; init; }
|
||||
public required string Release { get; init; }
|
||||
public required byte[][] Binaries { get; init; }
|
||||
}
|
||||
|
||||
private sealed record BinaryVerdict
|
||||
{
|
||||
public required string ScanId { get; init; }
|
||||
public required string ImageDigest { get; init; }
|
||||
public required DateTimeOffset ScannedAt { get; init; }
|
||||
public required BinaryEvidence[] Binaries { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -2,11 +2,13 @@ using System.Text;
|
||||
using StellaOps.Provenance.Attestation;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Provenance.Attestation.Tests;
|
||||
|
||||
public sealed class PromotionAttestationBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BuildAsync_SignsCanonicalPayloadAndAddsPredicateClaim()
|
||||
{
|
||||
var predicate = new PromotionPredicate(
|
||||
@@ -39,7 +41,8 @@ public sealed class PromotionAttestationBuilderTests
|
||||
canonicalJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task BuildAsync_MergesClaimsWithoutOverwritingPredicateType()
|
||||
{
|
||||
var predicate = new PromotionPredicate(
|
||||
|
||||
@@ -2,11 +2,13 @@ using System.Text;
|
||||
using StellaOps.Provenance.Attestation;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Provenance.Attestation.Tests;
|
||||
|
||||
public sealed class SignersTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HmacSigner_SignsAndAudits()
|
||||
{
|
||||
var key = new InMemoryKeyProvider("k1", Convert.FromHexString("0f0e0d0c0b0a09080706050403020100"));
|
||||
@@ -28,7 +30,8 @@ public sealed class SignersTests
|
||||
Assert.Empty(audit.Missing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HmacSigner_EnforcesRequiredClaims()
|
||||
{
|
||||
var key = new InMemoryKeyProvider("k-claims", Encoding.UTF8.GetBytes("secret"));
|
||||
@@ -43,7 +46,8 @@ public sealed class SignersTests
|
||||
Assert.Contains(audit.Missing, x => x.keyId == "k-claims" && x.claim == "sub");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RotatingKeyProvider_LogsRotationWhenNewKeyBecomesActive()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 11, 22, 10, 0, 0, TimeSpan.Zero);
|
||||
@@ -62,7 +66,8 @@ public sealed class SignersTests
|
||||
Assert.Equal("new", provider.KeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CosignSigner_UsesClientAndAudits()
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(await File.ReadAllTextAsync(Path.Combine("Fixtures", "cosign.sig"))); // fixture is deterministic
|
||||
@@ -89,7 +94,8 @@ public sealed class SignersTests
|
||||
Assert.Equal(request.Payload, call.payload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task KmsSigner_EnforcesRequiredClaims()
|
||||
{
|
||||
var signature = new byte[] { 0xCA, 0xFE, 0xBA, 0xBE };
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Provenance/StellaOps.Provenance.Attestation/StellaOps.Provenance.Attestation.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures/cosign.sig" CopyToOutputDirectory="Always" />
|
||||
|
||||
@@ -2,18 +2,22 @@ using System.Text;
|
||||
using StellaOps.Provenance.Attestation;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Provenance.Attestation.Tests;
|
||||
|
||||
public sealed class ToolEntrypointTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsInvalidOnMissingArgs()
|
||||
{
|
||||
var code = await ToolEntrypoint.RunAsync(Array.Empty<string>(), TextWriter.Null, new StringWriter(), new TestTimeProvider(DateTimeOffset.UtcNow));
|
||||
Assert.Equal(1, code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunAsync_VerifiesValidSignature()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("payload");
|
||||
|
||||
@@ -2,11 +2,14 @@ using System.Text;
|
||||
using StellaOps.Provenance.Attestation;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Provenance.Attestation.Tests;
|
||||
|
||||
public sealed class VerificationLibraryTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HmacVerifier_FailsWhenKeyExpired()
|
||||
{
|
||||
var key = new InMemoryKeyProvider("k1", Encoding.UTF8.GetBytes("secret"), DateTimeOffset.UtcNow.AddMinutes(-1));
|
||||
@@ -22,7 +25,8 @@ public sealed class VerificationLibraryTests
|
||||
Assert.Contains("time", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HmacVerifier_FailsWhenClockSkewTooLarge()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 11, 22, 12, 0, 0, TimeSpan.Zero);
|
||||
@@ -37,7 +41,8 @@ public sealed class VerificationLibraryTests
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void MerkleRootVerifier_DetectsMismatch()
|
||||
{
|
||||
var leaves = new[]
|
||||
@@ -54,7 +59,8 @@ public sealed class VerificationLibraryTests
|
||||
Assert.Equal("merkle root mismatch", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ChainOfCustodyVerifier_ComputesAggregate()
|
||||
{
|
||||
var hops = new[]
|
||||
|
||||
@@ -3,6 +3,7 @@ using FluentAssertions;
|
||||
using StellaOps.Replay.Core;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public class PolicySimulationInputLockValidatorTests
|
||||
@@ -19,7 +20,8 @@ public class PolicySimulationInputLockValidatorTests
|
||||
RequiredScopes = new[] { "policy:simulate:shadow" }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_passes_when_digests_match_and_shadow_scope_present()
|
||||
{
|
||||
var inputs = new PolicySimulationMaterializedInputs(
|
||||
@@ -38,7 +40,8 @@ public class PolicySimulationInputLockValidatorTests
|
||||
result.Reason.Should().Be("ok");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_detects_digest_drift()
|
||||
{
|
||||
var inputs = new PolicySimulationMaterializedInputs(
|
||||
@@ -57,7 +60,8 @@ public class PolicySimulationInputLockValidatorTests
|
||||
result.Reason.Should().Be("policy-bundle-drift");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_requires_shadow_mode_when_flagged()
|
||||
{
|
||||
var inputs = new PolicySimulationMaterializedInputs(
|
||||
@@ -76,7 +80,8 @@ public class PolicySimulationInputLockValidatorTests
|
||||
result.Reason.Should().Be("shadow-mode-required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_fails_when_lock_stale()
|
||||
{
|
||||
var inputs = new PolicySimulationMaterializedInputs(
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Audit.ReplayToken.Tests;
|
||||
|
||||
public sealed class ReplayTokenGeneratorTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Generate_SameInputs_ReturnsSameValue()
|
||||
{
|
||||
var cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
@@ -37,7 +39,8 @@ public sealed class ReplayTokenGeneratorTests
|
||||
Assert.Equal(token1.Canonical, token2.Canonical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Generate_IgnoresArrayOrdering()
|
||||
{
|
||||
var cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
@@ -63,7 +66,8 @@ public sealed class ReplayTokenGeneratorTests
|
||||
Assert.Equal(tokenA.Value, tokenB.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_MatchingInputs_ReturnsTrue()
|
||||
{
|
||||
var cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
@@ -76,7 +80,8 @@ public sealed class ReplayTokenGeneratorTests
|
||||
Assert.True(generator.Verify(token, request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Verify_DifferentInputs_ReturnsFalse()
|
||||
{
|
||||
var cryptoHash = DefaultCryptoHash.CreateForTests();
|
||||
@@ -90,7 +95,8 @@ public sealed class ReplayTokenGeneratorTests
|
||||
Assert.False(generator.Verify(token, different));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ReplayToken_Parse_RoundTripsCanonical()
|
||||
{
|
||||
var token = new ReplayToken("0123456789abcdef", DateTimeOffset.UnixEpoch);
|
||||
@@ -101,7 +107,8 @@ public sealed class ReplayTokenGeneratorTests
|
||||
Assert.Equal(token.Version, parsed.Version);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("replay")]
|
||||
[InlineData("replay:v1.0:SHA-256")]
|
||||
|
||||
@@ -18,5 +18,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Audit.ReplayToken\\StellaOps.Audit.ReplayToken.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -2,13 +2,15 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Evidence.Bundle.Tests;
|
||||
|
||||
public class EvidenceBundleTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2024, 12, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Builder_MinimalBundle_CreatesValid()
|
||||
{
|
||||
var bundle = new EvidenceBundleBuilder(_timeProvider)
|
||||
@@ -24,21 +26,24 @@ public class EvidenceBundleTests
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), bundle.CreatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Builder_MissingAlertId_Throws()
|
||||
{
|
||||
var builder = new EvidenceBundleBuilder(_timeProvider).WithArtifactId("sha256:abc");
|
||||
Assert.Throws<InvalidOperationException>(() => builder.Build());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Builder_MissingArtifactId_Throws()
|
||||
{
|
||||
var builder = new EvidenceBundleBuilder(_timeProvider).WithAlertId("ALERT-001");
|
||||
Assert.Throws<InvalidOperationException>(() => builder.Build());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Builder_WithAllEvidence_ComputesHashSet()
|
||||
{
|
||||
var bundle = new EvidenceBundleBuilder(_timeProvider)
|
||||
@@ -56,7 +61,8 @@ public class EvidenceBundleTests
|
||||
Assert.Equal(64, bundle.Hashes.CombinedHash.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeCompletenessScore_AllAvailable_Returns4()
|
||||
{
|
||||
var bundle = new EvidenceBundleBuilder(_timeProvider)
|
||||
@@ -71,7 +77,8 @@ public class EvidenceBundleTests
|
||||
Assert.Equal(4, bundle.ComputeCompletenessScore());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeCompletenessScore_NoneAvailable_Returns0()
|
||||
{
|
||||
var bundle = new EvidenceBundleBuilder(_timeProvider)
|
||||
@@ -82,7 +89,8 @@ public class EvidenceBundleTests
|
||||
Assert.Equal(0, bundle.ComputeCompletenessScore());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeCompletenessScore_PartialAvailable_ReturnsCorrect()
|
||||
{
|
||||
var bundle = new EvidenceBundleBuilder(_timeProvider)
|
||||
@@ -97,7 +105,8 @@ public class EvidenceBundleTests
|
||||
Assert.Equal(2, bundle.ComputeCompletenessScore());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CreateStatusSummary_ReturnsCorrectStatuses()
|
||||
{
|
||||
var bundle = new EvidenceBundleBuilder(_timeProvider)
|
||||
@@ -116,7 +125,8 @@ public class EvidenceBundleTests
|
||||
Assert.Equal(EvidenceStatus.Unavailable, summary.VexStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ToSigningPredicate_CreatesValidPredicate()
|
||||
{
|
||||
var bundle = new EvidenceBundleBuilder(_timeProvider)
|
||||
@@ -138,7 +148,8 @@ public class EvidenceBundleTests
|
||||
|
||||
public class EvidenceHashSetTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compute_DeterministicOutput()
|
||||
{
|
||||
var hashes1 = new Dictionary<string, string> { ["a"] = "hash1", ["b"] = "hash2" };
|
||||
@@ -150,7 +161,8 @@ public class EvidenceHashSetTests
|
||||
Assert.Equal(set1.CombinedHash, set2.CombinedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compute_DifferentInputs_DifferentHash()
|
||||
{
|
||||
var hashes1 = new Dictionary<string, string> { ["a"] = "hash1" };
|
||||
@@ -162,7 +174,8 @@ public class EvidenceHashSetTests
|
||||
Assert.NotEqual(set1.CombinedHash, set2.CombinedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Empty_CreatesEmptyHashSet()
|
||||
{
|
||||
var empty = EvidenceHashSet.Empty();
|
||||
@@ -172,7 +185,8 @@ public class EvidenceHashSetTests
|
||||
Assert.Equal("SHA-256", empty.Algorithm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compute_PreservesLabeledHashes()
|
||||
{
|
||||
var hashes = new Dictionary<string, string> { ["reachability"] = "h1", ["vex"] = "h2" };
|
||||
@@ -183,7 +197,8 @@ public class EvidenceHashSetTests
|
||||
Assert.Equal("h2", set.LabeledHashes["vex"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Compute_NullInput_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => EvidenceHashSet.Compute(null!));
|
||||
@@ -192,7 +207,8 @@ public class EvidenceHashSetTests
|
||||
|
||||
public class ServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AddEvidenceBundleServices_RegistersBuilder()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
@@ -203,7 +219,8 @@ public class ServiceCollectionExtensionsTests
|
||||
Assert.NotNull(builder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AddEvidenceBundleServices_WithTimeProvider_UsesProvided()
|
||||
{
|
||||
var fakeTime = new FakeTimeProvider();
|
||||
@@ -215,14 +232,16 @@ public class ServiceCollectionExtensionsTests
|
||||
Assert.Same(fakeTime, timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AddEvidenceBundleServices_NullServices_Throws()
|
||||
{
|
||||
IServiceCollection? services = null;
|
||||
Assert.Throws<ArgumentNullException>(() => services!.AddEvidenceBundleServices());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AddEvidenceBundleServices_NullTimeProvider_Throws()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Evidence.Bundle\StellaOps.Evidence.Bundle.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Gateway.WebService.Tests;
|
||||
|
||||
public class GatewayHealthTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
@@ -12,7 +13,8 @@ public class GatewayHealthTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HealthEndpoint_ReturnsOk()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -26,5 +26,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Gateway\StellaOps.Gateway.WebService\StellaOps.Gateway.WebService.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -6,6 +6,7 @@ using StellaOps.Microservice;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -33,7 +34,8 @@ public class EndpointDiscoveryServiceTests
|
||||
_logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_CallsDiscoveryProvider()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>();
|
||||
@@ -49,7 +51,8 @@ public class EndpointDiscoveryServiceTests
|
||||
_discoveryProviderMock.Verify(x => x.DiscoverEndpoints(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_CallsYamlLoader()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>();
|
||||
@@ -65,7 +68,8 @@ public class EndpointDiscoveryServiceTests
|
||||
_yamlLoaderMock.Verify(x => x.Load(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_PassesCodeEndpointsAndYamlConfigToMerger()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
@@ -95,7 +99,8 @@ public class EndpointDiscoveryServiceTests
|
||||
_mergerMock.Verify(x => x.Merge(codeEndpoints, yamlConfig), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_ReturnsMergedEndpoints()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
@@ -119,7 +124,8 @@ public class EndpointDiscoveryServiceTests
|
||||
result.Should().BeSameAs(mergedEndpoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_ContinuesWithNullYamlConfig_WhenLoaderReturnsNull()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
@@ -143,7 +149,8 @@ public class EndpointDiscoveryServiceTests
|
||||
result.Should().BeSameAs(codeEndpoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoverEndpoints_ContinuesWithNullYamlConfig_WhenLoaderThrows()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Reflection;
|
||||
using StellaOps.Microservice;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
// Test endpoint classes
|
||||
@@ -23,7 +24,8 @@ public record TestResponse;
|
||||
|
||||
public class EndpointDiscoveryTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StellaEndpointAttribute_StoresMethodAndPath()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -34,7 +36,8 @@ public class EndpointDiscoveryTests
|
||||
Assert.Equal("/api/test", attr.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StellaEndpointAttribute_NormalizesMethod()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -44,7 +47,8 @@ public class EndpointDiscoveryTests
|
||||
Assert.Equal("GET", attr.Method);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StellaEndpointAttribute_DefaultTimeoutIs30Seconds()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -54,7 +58,8 @@ public class EndpointDiscoveryTests
|
||||
Assert.Equal(30, attr.TimeoutSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ReflectionDiscovery_FindsEndpointsInCurrentAssembly()
|
||||
{
|
||||
// Arrange
|
||||
@@ -77,7 +82,8 @@ public class EndpointDiscoveryTests
|
||||
Assert.Contains(endpoints, e => e.Method == "POST" && e.Path == "/api/create");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ReflectionDiscovery_SetsServiceNameAndVersion()
|
||||
{
|
||||
// Arrange
|
||||
@@ -100,7 +106,8 @@ public class EndpointDiscoveryTests
|
||||
Assert.Equal("2.0.0", endpoint.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ReflectionDiscovery_SetsStreamingAndTimeout()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -6,6 +6,7 @@ using StellaOps.Microservice;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -22,7 +23,8 @@ public class EndpointOverrideMergerTests
|
||||
_merger = new EndpointOverrideMerger(_loggerMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_WithNullYamlConfig_ReturnsCodeEndpointsUnchanged()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
@@ -35,7 +37,8 @@ public class EndpointOverrideMergerTests
|
||||
result.Should().BeEquivalentTo(codeEndpoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_WithEmptyYamlConfig_ReturnsCodeEndpointsUnchanged()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
@@ -49,7 +52,8 @@ public class EndpointOverrideMergerTests
|
||||
result.Should().BeEquivalentTo(codeEndpoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_OverridesTimeout_WhenYamlSpecifiesTimeout()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
@@ -75,7 +79,8 @@ public class EndpointOverrideMergerTests
|
||||
result[0].DefaultTimeout.Should().Be(TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_OverridesStreaming_WhenYamlSpecifiesStreaming()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
@@ -101,7 +106,8 @@ public class EndpointOverrideMergerTests
|
||||
result[0].SupportsStreaming.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_OverridesClaims_WhenYamlSpecifiesClaims()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
@@ -132,7 +138,8 @@ public class EndpointOverrideMergerTests
|
||||
result[0].RequiringClaims[0].Value.Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_PreservesCodeDefaults_WhenYamlDoesNotOverride()
|
||||
{
|
||||
var originalTimeout = TimeSpan.FromSeconds(45);
|
||||
@@ -160,7 +167,8 @@ public class EndpointOverrideMergerTests
|
||||
result[0].SupportsStreaming.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_MatchesCaseInsensitively()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
@@ -186,7 +194,8 @@ public class EndpointOverrideMergerTests
|
||||
result[0].DefaultTimeout.Should().Be(TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_LeavesUnmatchedEndpointsUnchanged()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
@@ -216,7 +225,8 @@ public class EndpointOverrideMergerTests
|
||||
result[2].DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30)); // unchanged
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_LogsWarning_WhenYamlOverrideDoesNotMatchAnyEndpoint()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
@@ -248,7 +258,8 @@ public class EndpointOverrideMergerTests
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_AppliesMultipleOverrides()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
@@ -282,7 +293,8 @@ public class EndpointOverrideMergerTests
|
||||
result[1].DefaultTimeout.Should().Be(TimeSpan.FromMinutes(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_PreservesOriginalEndpointProperties()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
@@ -322,7 +334,8 @@ public class EndpointOverrideMergerTests
|
||||
result[0].HandlerType.Should().Be(typeof(object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Merge_YamlOverridesCodeClaims_Completely()
|
||||
{
|
||||
var codeEndpoints = new List<EndpointDescriptor>
|
||||
|
||||
@@ -3,6 +3,7 @@ using StellaOps.Microservice;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
public class EndpointRegistryTests
|
||||
@@ -19,7 +20,8 @@ public class EndpointRegistryTests
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_ExactMatch_ReturnsEndpoint()
|
||||
{
|
||||
var registry = new EndpointRegistry();
|
||||
@@ -34,7 +36,8 @@ public class EndpointRegistryTests
|
||||
match.PathParameters.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_MethodMismatch_ReturnsFalse()
|
||||
{
|
||||
var registry = new EndpointRegistry();
|
||||
@@ -46,7 +49,8 @@ public class EndpointRegistryTests
|
||||
match.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_PathMismatch_ReturnsFalse()
|
||||
{
|
||||
var registry = new EndpointRegistry();
|
||||
@@ -58,7 +62,8 @@ public class EndpointRegistryTests
|
||||
match.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_WithPathParameter_ExtractsParameter()
|
||||
{
|
||||
var registry = new EndpointRegistry();
|
||||
@@ -72,7 +77,8 @@ public class EndpointRegistryTests
|
||||
match.PathParameters["id"].Should().Be("123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_MethodCaseInsensitive_ReturnsMatch()
|
||||
{
|
||||
var registry = new EndpointRegistry();
|
||||
@@ -84,7 +90,8 @@ public class EndpointRegistryTests
|
||||
match.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_PathCaseInsensitive_ReturnsMatch()
|
||||
{
|
||||
var registry = new EndpointRegistry();
|
||||
@@ -96,7 +103,8 @@ public class EndpointRegistryTests
|
||||
match.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RegisterAll_MultipeEndpoints_AllRegistered()
|
||||
{
|
||||
var registry = new EndpointRegistry();
|
||||
@@ -112,7 +120,8 @@ public class EndpointRegistryTests
|
||||
registry.GetAllEndpoints().Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetAllEndpoints_ReturnsAllRegistered()
|
||||
{
|
||||
var registry = new EndpointRegistry();
|
||||
@@ -128,7 +137,8 @@ public class EndpointRegistryTests
|
||||
all.Should().Contain(endpoint2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_FirstMatchWins_WhenMultiplePossible()
|
||||
{
|
||||
var registry = new EndpointRegistry();
|
||||
@@ -145,7 +155,8 @@ public class EndpointRegistryTests
|
||||
match!.Endpoint.Should().Be(endpoint1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_EmptyRegistry_ReturnsFalse()
|
||||
{
|
||||
var registry = new EndpointRegistry();
|
||||
@@ -156,7 +167,8 @@ public class EndpointRegistryTests
|
||||
match.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_CaseSensitive_RespectsSetting()
|
||||
{
|
||||
var registry = new EndpointRegistry(caseInsensitive: false);
|
||||
|
||||
@@ -2,6 +2,7 @@ using FluentAssertions;
|
||||
using StellaOps.Microservice;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -9,7 +10,8 @@ namespace StellaOps.Microservice.Tests;
|
||||
/// </summary>
|
||||
public class MicroserviceYamlConfigTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void MicroserviceYamlConfig_DefaultsToEmptyEndpoints()
|
||||
{
|
||||
var config = new MicroserviceYamlConfig();
|
||||
@@ -18,7 +20,8 @@ public class MicroserviceYamlConfigTests
|
||||
config.Endpoints.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EndpointOverrideConfig_DefaultsToEmptyStrings()
|
||||
{
|
||||
var config = new EndpointOverrideConfig();
|
||||
@@ -30,7 +33,8 @@ public class MicroserviceYamlConfigTests
|
||||
config.RequiringClaims.Should().BeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("30s", 30)]
|
||||
[InlineData("60s", 60)]
|
||||
[InlineData("1s", 1)]
|
||||
@@ -44,7 +48,8 @@ public class MicroserviceYamlConfigTests
|
||||
result.Should().Be(TimeSpan.FromSeconds(expectedSeconds));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("5m", 5)]
|
||||
[InlineData("10m", 10)]
|
||||
[InlineData("1m", 1)]
|
||||
@@ -58,7 +63,8 @@ public class MicroserviceYamlConfigTests
|
||||
result.Should().Be(TimeSpan.FromMinutes(expectedMinutes));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("1h", 1)]
|
||||
[InlineData("2h", 2)]
|
||||
[InlineData("24h", 24)]
|
||||
@@ -72,7 +78,8 @@ public class MicroserviceYamlConfigTests
|
||||
result.Should().Be(TimeSpan.FromHours(expectedHours));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("00:00:30", 30)]
|
||||
[InlineData("00:05:00", 300)]
|
||||
[InlineData("01:00:00", 3600)]
|
||||
@@ -86,7 +93,8 @@ public class MicroserviceYamlConfigTests
|
||||
result.Should().Be(TimeSpan.FromSeconds(expectedSeconds));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
@@ -99,7 +107,8 @@ public class MicroserviceYamlConfigTests
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("invalid")]
|
||||
[InlineData("abc")]
|
||||
[InlineData("30x")]
|
||||
@@ -112,7 +121,8 @@ public class MicroserviceYamlConfigTests
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ClaimRequirementConfig_ToClaimRequirement_ConvertsCorrectly()
|
||||
{
|
||||
var config = new ClaimRequirementConfig
|
||||
@@ -127,7 +137,8 @@ public class MicroserviceYamlConfigTests
|
||||
result.Value.Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ClaimRequirementConfig_ToClaimRequirement_HandlesNullValue()
|
||||
{
|
||||
var config = new ClaimRequirementConfig
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Microservice;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -29,7 +30,8 @@ public class MicroserviceYamlLoaderTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Load_ReturnsNull_WhenConfigFilePathIsNull()
|
||||
{
|
||||
var options = new StellaMicroserviceOptions
|
||||
@@ -46,7 +48,8 @@ public class MicroserviceYamlLoaderTests : IDisposable
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Load_ReturnsNull_WhenConfigFilePathIsEmpty()
|
||||
{
|
||||
var options = new StellaMicroserviceOptions
|
||||
@@ -63,7 +66,8 @@ public class MicroserviceYamlLoaderTests : IDisposable
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Load_ReturnsNull_WhenFileDoesNotExist()
|
||||
{
|
||||
var options = new StellaMicroserviceOptions
|
||||
@@ -80,7 +84,8 @@ public class MicroserviceYamlLoaderTests : IDisposable
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Load_ParsesValidYaml()
|
||||
{
|
||||
var yamlContent = """
|
||||
@@ -111,7 +116,8 @@ public class MicroserviceYamlLoaderTests : IDisposable
|
||||
result.Endpoints[0].SupportsStreaming.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Load_ParsesMultipleEndpoints()
|
||||
{
|
||||
var yamlContent = """
|
||||
@@ -143,7 +149,8 @@ public class MicroserviceYamlLoaderTests : IDisposable
|
||||
result!.Endpoints.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Load_ParsesClaimRequirements()
|
||||
{
|
||||
var yamlContent = """
|
||||
@@ -178,7 +185,8 @@ public class MicroserviceYamlLoaderTests : IDisposable
|
||||
result.Endpoints[0].RequiringClaims![1].Value.Should().Be("delete");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Load_HandlesEmptyEndpointsList()
|
||||
{
|
||||
var yamlContent = """
|
||||
@@ -201,7 +209,8 @@ public class MicroserviceYamlLoaderTests : IDisposable
|
||||
result!.Endpoints.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Load_IgnoresUnknownProperties()
|
||||
{
|
||||
var yamlContent = """
|
||||
@@ -228,7 +237,8 @@ public class MicroserviceYamlLoaderTests : IDisposable
|
||||
result!.Endpoints.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Load_ThrowsOnInvalidYaml()
|
||||
{
|
||||
var yamlContent = """
|
||||
@@ -252,7 +262,8 @@ public class MicroserviceYamlLoaderTests : IDisposable
|
||||
act.Should().Throw<Exception>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Load_ResolvesRelativePath()
|
||||
{
|
||||
var yamlContent = """
|
||||
|
||||
@@ -7,6 +7,8 @@ using StellaOps.Router.Common.Frames;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
public sealed class RequestDispatcherTests
|
||||
@@ -17,7 +19,8 @@ public sealed class RequestDispatcherTests
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DispatchAsync_WhenEndpointNotFound_Returns404()
|
||||
{
|
||||
var registry = new EndpointRegistry();
|
||||
@@ -44,7 +47,8 @@ public sealed class RequestDispatcherTests
|
||||
Encoding.UTF8.GetString(response.Payload.Span).Should().Be("Not Found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DispatchAsync_WhenBodyEmpty_BindsFromPathAndQueryParameters()
|
||||
{
|
||||
var registry = new EndpointRegistry();
|
||||
@@ -86,7 +90,8 @@ public sealed class RequestDispatcherTests
|
||||
dto.Filter.Should().Be("active");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DispatchAsync_WhenBodyPresent_PathAndQueryOverrideJsonProperties()
|
||||
{
|
||||
var registry = new EndpointRegistry();
|
||||
|
||||
@@ -2,11 +2,13 @@ using StellaOps.Microservice;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
public class StellaMicroserviceOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StellaMicroserviceOptions_CanBeCreated()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -24,7 +26,8 @@ public class StellaMicroserviceOptionsTests
|
||||
Assert.NotEmpty(options.InstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RouterEndpointConfig_CanBeCreated()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -41,7 +44,8 @@ public class StellaMicroserviceOptionsTests
|
||||
Assert.Equal(TransportType.Tcp, config.TransportType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_ThrowsIfServiceNameEmpty()
|
||||
{
|
||||
// Arrange
|
||||
@@ -56,7 +60,8 @@ public class StellaMicroserviceOptionsTests
|
||||
Assert.Throws<InvalidOperationException>(() => options.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_ThrowsIfVersionInvalid()
|
||||
{
|
||||
// Arrange
|
||||
@@ -72,7 +77,8 @@ public class StellaMicroserviceOptionsTests
|
||||
Assert.Contains("not valid semver", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_ThrowsIfNoRouters()
|
||||
{
|
||||
// Arrange
|
||||
@@ -88,7 +94,8 @@ public class StellaMicroserviceOptionsTests
|
||||
Assert.Contains("router endpoint is required", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_AcceptsValidSemver()
|
||||
{
|
||||
// Arrange
|
||||
@@ -104,7 +111,8 @@ public class StellaMicroserviceOptionsTests
|
||||
options.Validate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_AcceptsSemverWithPrerelease()
|
||||
{
|
||||
// Arrange
|
||||
@@ -120,7 +128,8 @@ public class StellaMicroserviceOptionsTests
|
||||
options.Validate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DefaultHeartbeatInterval_Is10Seconds()
|
||||
{
|
||||
// Arrange & Act
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Microservice\StellaOps.Microservice.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -4,6 +4,8 @@ using FluentAssertions;
|
||||
using StellaOps.Microservice;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Microservice.Tests;
|
||||
|
||||
public class TypedEndpointAdapterTests
|
||||
@@ -41,7 +43,8 @@ public class TypedEndpointAdapterTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Adapt_TypedWithRequest_DeserializesAndSerializes()
|
||||
{
|
||||
var handler = new TestTypedHandler();
|
||||
@@ -69,7 +72,8 @@ public class TypedEndpointAdapterTests
|
||||
result.Success.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Adapt_TypedNoRequest_SerializesResponse()
|
||||
{
|
||||
var handler = new TestNoRequestHandler();
|
||||
@@ -93,7 +97,8 @@ public class TypedEndpointAdapterTests
|
||||
result!.Message.Should().Be("No request needed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Adapt_RawHandler_PassesThroughDirectly()
|
||||
{
|
||||
var handler = new TestRawHandler();
|
||||
@@ -112,7 +117,8 @@ public class TypedEndpointAdapterTests
|
||||
response.StatusCode.Should().Be(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Adapt_InvalidJson_ReturnsBadRequest()
|
||||
{
|
||||
var handler = new TestTypedHandler();
|
||||
@@ -131,7 +137,8 @@ public class TypedEndpointAdapterTests
|
||||
response.StatusCode.Should().Be(400);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Adapt_EmptyBody_ReturnsBadRequest()
|
||||
{
|
||||
var handler = new TestTypedHandler();
|
||||
@@ -150,7 +157,8 @@ public class TypedEndpointAdapterTests
|
||||
response.StatusCode.Should().Be(400);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Adapt_WithCancellation_PropagatesCancellation()
|
||||
{
|
||||
var handler = new CancellableHandler();
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Common.Tests;
|
||||
|
||||
public class FrameTypeTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FrameType_HasExpectedValues()
|
||||
{
|
||||
// Verify all expected frame types exist
|
||||
@@ -16,7 +18,8 @@ public class FrameTypeTests
|
||||
Assert.True(Enum.IsDefined(typeof(FrameType), FrameType.Cancel));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TransportType_HasExpectedValues()
|
||||
{
|
||||
// Verify all expected transport types exist (no HTTP per spec)
|
||||
@@ -27,7 +30,8 @@ public class FrameTypeTests
|
||||
Assert.True(Enum.IsDefined(typeof(TransportType), TransportType.RabbitMq));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void InstanceHealthStatus_HasExpectedValues()
|
||||
{
|
||||
// Verify all expected health statuses exist
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Router.Common\StellaOps.Router.Common.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -6,11 +6,14 @@ using Microsoft.Extensions.Options;
|
||||
using StellaOps.Router.Common.Enums;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Config.Tests;
|
||||
|
||||
public class RouterConfigTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RouterConfig_HasDefaultValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -23,7 +26,8 @@ public class RouterConfigTests
|
||||
config.StaticInstances.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RoutingOptions_HasDefaultValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -37,7 +41,8 @@ public class RouterConfigTests
|
||||
options.DefaultTimeout.Should().Be(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void StaticInstanceConfig_RequiredProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -59,7 +64,8 @@ public class RouterConfigTests
|
||||
instance.Weight.Should().Be(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RouterConfigOptions_HasDefaultValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -76,7 +82,8 @@ public class RouterConfigTests
|
||||
|
||||
public class RouterConfigProviderTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validate_ReturnsSuccess_ForValidConfig()
|
||||
{
|
||||
// Arrange
|
||||
@@ -92,7 +99,8 @@ public class RouterConfigProviderTests
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Current_ReturnsDefaultConfig_WhenNoFileSpecified()
|
||||
{
|
||||
// Arrange
|
||||
@@ -112,7 +120,8 @@ public class RouterConfigProviderTests
|
||||
|
||||
public class ConfigValidationTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Validation_Fails_WhenPayloadLimitsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
@@ -127,7 +136,8 @@ public class ConfigValidationTests
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConfigValidationResult_Success_HasNoErrors()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -138,7 +148,8 @@ public class ConfigValidationTests
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ConfigValidationResult_WithErrors_IsNotValid()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -155,7 +166,8 @@ public class ConfigValidationTests
|
||||
|
||||
public class ServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AddRouterConfig_RegistersServices()
|
||||
{
|
||||
// Arrange
|
||||
@@ -172,7 +184,8 @@ public class ServiceCollectionExtensionsTests
|
||||
configProvider.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AddRouterConfig_WithPath_SetsConfigPath()
|
||||
{
|
||||
// Arrange
|
||||
@@ -191,7 +204,8 @@ public class ServiceCollectionExtensionsTests
|
||||
configProvider!.Options.ConfigPath.Should().Be(path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AddRouterConfigFromYaml_SetsConfigPath()
|
||||
{
|
||||
// Arrange
|
||||
@@ -214,7 +228,8 @@ public class ServiceCollectionExtensionsTests
|
||||
|
||||
public class ConfigChangedEventArgsTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_SetsProperties()
|
||||
{
|
||||
// Arrange
|
||||
@@ -243,7 +258,8 @@ public class HotReloadTests : IDisposable
|
||||
_tempConfigPath = Path.Combine(_tempDir, "router.yaml");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task HotReload_UpdatesConfig_WhenFileChanges()
|
||||
{
|
||||
// Arrange
|
||||
@@ -296,7 +312,8 @@ routing:
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReloadAsync_LoadsNewConfig()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Router.Config\StellaOps.Router.Config.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -8,11 +8,13 @@ using StellaOps.Router.Gateway.State;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class ConnectionManagerTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StartAsync_WhenHelloInvalid_RejectsAndClosesChannel()
|
||||
{
|
||||
var (manager, server, registry, routingState) = Create();
|
||||
@@ -59,7 +61,8 @@ public sealed class ConnectionManagerTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WhenClientDisconnects_RemovesFromRoutingState()
|
||||
{
|
||||
var (manager, server, registry, routingState) = Create();
|
||||
@@ -102,7 +105,8 @@ public sealed class ConnectionManagerTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WhenMultipleClientsConnect_TracksAndCleansIndependently()
|
||||
{
|
||||
var (manager, server, registry, routingState) = Create();
|
||||
|
||||
@@ -6,11 +6,13 @@ using StellaOps.Router.Gateway.Configuration;
|
||||
using StellaOps.Router.Gateway.Routing;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class DefaultRoutingPluginTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_WhenNoCandidates_ReturnsNull()
|
||||
{
|
||||
var plugin = CreatePlugin(gatewayRegion: "eu1");
|
||||
@@ -38,7 +40,8 @@ public sealed class DefaultRoutingPluginTests
|
||||
decision.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_WhenRequestedVersionDoesNotMatch_ReturnsNull()
|
||||
{
|
||||
var plugin = CreatePlugin(
|
||||
@@ -63,7 +66,8 @@ public sealed class DefaultRoutingPluginTests
|
||||
decision.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_PrefersHealthyOverDegraded()
|
||||
{
|
||||
var plugin = CreatePlugin(
|
||||
@@ -93,7 +97,8 @@ public sealed class DefaultRoutingPluginTests
|
||||
decision!.Connection.ConnectionId.Should().Be("inv-healthy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_PrefersLocalRegionOverRemote()
|
||||
{
|
||||
var plugin = CreatePlugin(
|
||||
@@ -120,7 +125,8 @@ public sealed class DefaultRoutingPluginTests
|
||||
decision!.Connection.ConnectionId.Should().Be("inv-eu1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_WhenNoLocal_UsesNeighborRegion()
|
||||
{
|
||||
var plugin = CreatePlugin(
|
||||
@@ -148,7 +154,8 @@ public sealed class DefaultRoutingPluginTests
|
||||
decision!.Connection.ConnectionId.Should().Be("inv-eu2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ChooseInstanceAsync_WhenTied_UsesRoundRobin()
|
||||
{
|
||||
var plugin = CreatePlugin(
|
||||
|
||||
@@ -4,11 +4,13 @@ using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Gateway.State;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class InMemoryRoutingStateTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveEndpoint_WhenExactMatch_ReturnsEndpointDescriptor()
|
||||
{
|
||||
var routingState = new InMemoryRoutingState();
|
||||
@@ -33,7 +35,8 @@ public sealed class InMemoryRoutingStateTests
|
||||
resolved.Path.Should().Be("/items");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveEndpoint_WhenTemplateMatch_ReturnsEndpointDescriptor()
|
||||
{
|
||||
var routingState = new InMemoryRoutingState();
|
||||
@@ -55,7 +58,8 @@ public sealed class InMemoryRoutingStateTests
|
||||
resolved!.Path.Should().Be("/items/{sku}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RemoveConnection_RemovesEndpointsFromIndex()
|
||||
{
|
||||
var routingState = new InMemoryRoutingState();
|
||||
@@ -78,7 +82,8 @@ public sealed class InMemoryRoutingStateTests
|
||||
routingState.GetAllConnections().Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetConnectionsFor_FiltersByServiceAndVersion()
|
||||
{
|
||||
var routingState = new InMemoryRoutingState();
|
||||
|
||||
@@ -2,11 +2,13 @@ using FluentAssertions;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class InMemoryValkeyRateLimitStoreTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task IncrementAndCheckAsync_UsesSmallestWindowAsRepresentativeWhenAllowed()
|
||||
{
|
||||
var store = new InMemoryValkeyRateLimitStore();
|
||||
@@ -25,7 +27,8 @@ public sealed class InMemoryValkeyRateLimitStoreTests
|
||||
result.RetryAfterSeconds.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task IncrementAndCheckAsync_DeniesWhenLimitExceeded()
|
||||
{
|
||||
var store = new InMemoryValkeyRateLimitStore();
|
||||
|
||||
@@ -2,11 +2,13 @@ using FluentAssertions;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class InstanceRateLimiterTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryAcquire_ReportsMostConstrainedRuleWhenAllowed()
|
||||
{
|
||||
var limiter = new InstanceRateLimiter(
|
||||
@@ -24,7 +26,8 @@ public sealed class InstanceRateLimiterTests
|
||||
decision.CurrentCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryAcquire_DeniesWhenAnyRuleIsExceeded()
|
||||
{
|
||||
var limiter = new InstanceRateLimiter(
|
||||
|
||||
@@ -2,11 +2,13 @@ using FluentAssertions;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class LimitInheritanceResolverTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveEnvironmentTarget_UsesEnvironmentDefaultsWhenNoMicroserviceOverride()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
@@ -32,7 +34,8 @@ public sealed class LimitInheritanceResolverTests
|
||||
target.Rules[0].MaxRequests.Should().Be(600);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveEnvironmentTarget_UsesMicroserviceOverrideWhenPresent()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
@@ -65,7 +68,8 @@ public sealed class LimitInheritanceResolverTests
|
||||
target.Rules[0].MaxRequests.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveEnvironmentTarget_DisablesWhenNoRulesAtAnyLevel()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
@@ -84,7 +88,8 @@ public sealed class LimitInheritanceResolverTests
|
||||
target.Enabled.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveEnvironmentTarget_UsesRouteOverrideWhenPresent()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
@@ -126,7 +131,8 @@ public sealed class LimitInheritanceResolverTests
|
||||
target.Rules[0].MaxRequests.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ResolveEnvironmentTarget_DoesNotTreatRouteAsOverrideWhenItHasNoRules()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
|
||||
@@ -15,11 +15,14 @@ using StellaOps.Router.Gateway.Middleware;
|
||||
using StellaOps.Router.Gateway.State;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class MiddlewareErrorScenarioTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EndpointResolutionMiddleware_WhenNoEndpoint_Returns404StructuredError()
|
||||
{
|
||||
var context = CreateContext(method: "GET", path: "/missing");
|
||||
@@ -45,7 +48,8 @@ public sealed class MiddlewareErrorScenarioTests
|
||||
body.GetProperty("traceId").GetString().Should().Be("trace-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RoutingDecisionMiddleware_WhenNoInstances_Returns503StructuredError()
|
||||
{
|
||||
var context = CreateContext(method: "GET", path: "/items");
|
||||
@@ -84,7 +88,8 @@ public sealed class MiddlewareErrorScenarioTests
|
||||
body.GetProperty("version").GetString().Should().Be("1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task AuthorizationMiddleware_WhenMissingClaim_Returns403StructuredError()
|
||||
{
|
||||
var context = CreateContext(method: "GET", path: "/items");
|
||||
@@ -128,7 +133,8 @@ public sealed class MiddlewareErrorScenarioTests
|
||||
body.GetProperty("details").GetProperty("requiredClaimValue").GetString().Should().Be("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GlobalErrorHandlerMiddleware_WhenUnhandledException_Returns500StructuredError()
|
||||
{
|
||||
var context = CreateContext(method: "GET", path: "/boom");
|
||||
|
||||
@@ -3,11 +3,13 @@ using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class RateLimitConfigTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Load_BindsRoutesAndRules()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
@@ -41,7 +43,8 @@ public sealed class RateLimitConfigTests
|
||||
route.Rules[0].MaxRequests.Should().Be(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Load_ThrowsForInvalidRegexRoute()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
|
||||
@@ -7,11 +7,14 @@ using StellaOps.Router.Gateway;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class RateLimitMiddlewareTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InvokeAsync_EnforcesEnvironmentLimit_WithRetryAfterAndJsonBody()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
|
||||
@@ -2,11 +2,13 @@ using FluentAssertions;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class RateLimitRouteMatcherTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_ExactBeatsPrefixAndRegex()
|
||||
{
|
||||
var microservice = new MicroserviceLimitsConfig
|
||||
@@ -43,7 +45,8 @@ public sealed class RateLimitRouteMatcherTests
|
||||
match!.Value.Name.Should().Be("exact");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryMatch_LongestPrefixWins()
|
||||
{
|
||||
var microservice = new MicroserviceLimitsConfig
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Router.Gateway.RateLimit;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class RateLimitServiceTests
|
||||
@@ -26,7 +27,8 @@ public sealed class RateLimitServiceTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CheckLimitAsync_DoesNotInvokeEnvironmentLimiterUntilActivationGateTriggers()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
@@ -58,7 +60,8 @@ public sealed class RateLimitServiceTests
|
||||
store.Calls.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CheckLimitAsync_EnforcesPerRouteEnvironmentRules()
|
||||
{
|
||||
var config = new RateLimitConfig
|
||||
|
||||
@@ -5,11 +5,14 @@ using StellaOps.Router.Gateway.Configuration;
|
||||
using StellaOps.Router.Gateway.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Gateway.Tests;
|
||||
|
||||
public sealed class RouterNodeConfigValidationTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RouterNodeConfig_WhenRegionMissing_ThrowsOptionsValidationException()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
@@ -22,7 +25,8 @@ public sealed class RouterNodeConfigValidationTests
|
||||
act.Should().Throw<OptionsValidationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RouterNodeConfig_WhenRegionProvided_GeneratesNodeIdIfMissing()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Router.Gateway\StellaOps.Router.Gateway.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -4,6 +4,7 @@ using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Transport.InMemory.Tests;
|
||||
|
||||
public class CancelFlowTests
|
||||
@@ -24,7 +25,8 @@ public class CancelFlowTests
|
||||
_client = provider.GetRequiredService<InMemoryTransportClient>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SendCancelAsync_SendsCancelFrame()
|
||||
{
|
||||
// Arrange
|
||||
@@ -57,7 +59,8 @@ public class CancelFlowTests
|
||||
Assert.Equal(1, _registry.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task OnCancelReceived_IsInvoked()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -6,6 +6,7 @@ using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Transport.InMemory.Tests;
|
||||
|
||||
public class HelloHeartbeatFlowTests
|
||||
@@ -26,7 +27,8 @@ public class HelloHeartbeatFlowTests
|
||||
_client = provider.GetRequiredService<InMemoryTransportClient>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ConnectAsync_SendsHelloAndRegistersEndpoints()
|
||||
{
|
||||
// Arrange
|
||||
@@ -61,7 +63,8 @@ public class HelloHeartbeatFlowTests
|
||||
Assert.Equal(TransportType.InMemory, connections[0].TransportType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SendHeartbeatAsync_SendsHeartbeatFrame()
|
||||
{
|
||||
// Arrange
|
||||
@@ -92,7 +95,8 @@ public class HelloHeartbeatFlowTests
|
||||
Assert.Equal(1, _registry.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DisconnectAsync_RemovesConnection()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -3,11 +3,14 @@ using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Transport.InMemory.Tests;
|
||||
|
||||
public class InMemoryChannelTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ToMicroservice_WritesAndReads()
|
||||
{
|
||||
// Arrange
|
||||
@@ -28,7 +31,8 @@ public class InMemoryChannelTests
|
||||
Assert.Equal("corr-1", readFrame.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ToGateway_WritesAndReads()
|
||||
{
|
||||
// Arrange
|
||||
@@ -49,7 +53,8 @@ public class InMemoryChannelTests
|
||||
Assert.Equal("corr-1", readFrame.CorrelationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Dispose_CancelsLifetimeToken()
|
||||
{
|
||||
// Arrange
|
||||
@@ -62,7 +67,8 @@ public class InMemoryChannelTests
|
||||
Assert.True(channel.LifetimeToken.IsCancellationRequested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Dispose_CompletesChannels()
|
||||
{
|
||||
// Arrange
|
||||
@@ -76,7 +82,8 @@ public class InMemoryChannelTests
|
||||
Assert.True(channel.ToGateway.Reader.Completion.IsCompleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BoundedChannel_RespectsBufferSize()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -86,7 +93,8 @@ public class InMemoryChannelTests
|
||||
Assert.NotNull(channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Instance_CanBeSetAndRetrieved()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Transport.InMemory.Tests;
|
||||
|
||||
public class InMemoryConnectionRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CreateChannel_CreatesNewChannel()
|
||||
{
|
||||
// Arrange
|
||||
@@ -20,7 +23,8 @@ public class InMemoryConnectionRegistryTests
|
||||
Assert.Equal(1, registry.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CreateChannel_ThrowsIfDuplicate()
|
||||
{
|
||||
// Arrange
|
||||
@@ -31,7 +35,8 @@ public class InMemoryConnectionRegistryTests
|
||||
Assert.Throws<InvalidOperationException>(() => registry.CreateChannel("conn-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetChannel_ReturnsNullForUnknown()
|
||||
{
|
||||
// Arrange
|
||||
@@ -44,7 +49,8 @@ public class InMemoryConnectionRegistryTests
|
||||
Assert.Null(channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void GetChannel_ReturnsExistingChannel()
|
||||
{
|
||||
// Arrange
|
||||
@@ -58,7 +64,8 @@ public class InMemoryConnectionRegistryTests
|
||||
Assert.Same(created, retrieved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RemoveChannel_RemovesAndDisposes()
|
||||
{
|
||||
// Arrange
|
||||
@@ -74,7 +81,8 @@ public class InMemoryConnectionRegistryTests
|
||||
Assert.True(channel.LifetimeToken.IsCancellationRequested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RemoveChannel_ReturnsFalseForUnknown()
|
||||
{
|
||||
// Arrange
|
||||
@@ -87,7 +95,8 @@ public class InMemoryConnectionRegistryTests
|
||||
Assert.False(removed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Dispose_DisposesAllChannels()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Transport.InMemory.Tests;
|
||||
|
||||
public class RequestResponseFlowTests
|
||||
@@ -25,7 +26,8 @@ public class RequestResponseFlowTests
|
||||
_client = provider.GetRequiredService<InMemoryTransportClient>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RequestResponse_RoundTrip()
|
||||
{
|
||||
// Arrange
|
||||
@@ -93,7 +95,8 @@ public class RequestResponseFlowTests
|
||||
Assert.Equal(1, _registry.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SendRequestAsync_TimesOut()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.Router.Transport.InMemory\StellaOps.Router.Transport.InMemory.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,6 +5,8 @@ using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Transport.InMemory;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Transport.InMemory.Tests;
|
||||
|
||||
public class StreamingFlowTests
|
||||
@@ -25,7 +27,8 @@ public class StreamingFlowTests
|
||||
_client = provider.GetRequiredService<InMemoryTransportClient>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SendStreamingAsync_SendsHeaderAndDataFrames()
|
||||
{
|
||||
// Arrange
|
||||
@@ -93,7 +96,8 @@ public class StreamingFlowTests
|
||||
Assert.Equal(1, _registry.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RequestStreamData_IsHandled()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Router.Transport.Udp/StellaOps.Router.Transport.Udp.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -8,6 +8,8 @@ using StellaOps.Router.Common.Enums;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Router.Transport.Udp.Tests;
|
||||
|
||||
public class UdpTransportTests
|
||||
@@ -17,7 +19,8 @@ public class UdpTransportTests
|
||||
|
||||
private static int GetNextPort() => BasePort + Interlocked.Increment(ref _portOffset);
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void UdpFrameProtocol_SerializeAndParse_RoundTrip()
|
||||
{
|
||||
// Arrange
|
||||
@@ -38,7 +41,8 @@ public class UdpTransportTests
|
||||
Assert.Equal(originalFrame.Payload.ToArray(), parsed.Payload.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void UdpFrameProtocol_ParseFrame_WithEmptyPayload()
|
||||
{
|
||||
// Arrange
|
||||
@@ -58,7 +62,8 @@ public class UdpTransportTests
|
||||
Assert.Empty(parsed.Payload.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void UdpFrameProtocol_ParseFrame_ThrowsOnTooSmallDatagram()
|
||||
{
|
||||
// Arrange
|
||||
@@ -68,7 +73,8 @@ public class UdpTransportTests
|
||||
Assert.Throws<InvalidOperationException>(() => UdpFrameProtocol.ParseFrame(tooSmall));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void PayloadTooLargeException_HasCorrectProperties()
|
||||
{
|
||||
// Arrange & Act
|
||||
@@ -81,7 +87,8 @@ public class UdpTransportTests
|
||||
Assert.Contains("8192", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UdpTransportServer_StartsAndStops()
|
||||
{
|
||||
// Arrange
|
||||
@@ -108,7 +115,8 @@ public class UdpTransportTests
|
||||
await server.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UdpTransportClient_ConnectsAndDisconnects()
|
||||
{
|
||||
// Arrange
|
||||
@@ -153,7 +161,8 @@ public class UdpTransportTests
|
||||
await server.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UdpTransport_RequestResponse_Works()
|
||||
{
|
||||
// Arrange
|
||||
@@ -234,7 +243,8 @@ public class UdpTransportTests
|
||||
await server.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UdpTransport_PayloadTooLarge_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
@@ -300,7 +310,8 @@ public class UdpTransportTests
|
||||
await server.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UdpTransport_StreamingNotSupported_ThrowsNotSupportedException()
|
||||
{
|
||||
// Arrange
|
||||
@@ -347,7 +358,8 @@ public class UdpTransportTests
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UdpTransport_Timeout_ThrowsTimeoutException()
|
||||
{
|
||||
// Arrange
|
||||
@@ -411,7 +423,8 @@ public class UdpTransportTests
|
||||
await server.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ServiceCollectionExtensions_RegistersServerCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
@@ -433,7 +446,8 @@ public class UdpTransportTests
|
||||
Assert.Same(server, udpServer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ServiceCollectionExtensions_RegistersClientCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
@@ -459,7 +473,8 @@ public class UdpTransportTests
|
||||
Assert.Same(microserviceTransport, udpClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UdpTransport_HeartbeatSent()
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\VulnExplorer\\StellaOps.VulnExplorer.Api\\StellaOps.VulnExplorer.Api.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.VulnExplorer.Api.Models;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.VulnExplorer.Api.Tests;
|
||||
|
||||
public class VulnApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
@@ -15,7 +16,8 @@ public class VulnApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
this.factory = factory.WithWebHostBuilder(_ => { });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_ReturnsDeterministicOrder()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
@@ -29,7 +31,8 @@ public class VulnApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
Assert.Equal(new[] { "vuln-0001", "vuln-0002" }, payload!.Items.Select(v => v.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_FiltersByCve()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
@@ -43,7 +46,8 @@ public class VulnApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
Assert.Equal("vuln-0002", payload.Items[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Detail_ReturnsNotFoundWhenMissing()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
@@ -53,7 +57,8 @@ public class VulnApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Detail_ReturnsRationaleAndPaths()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BinaryLookupBenchmarks.cs
|
||||
// Sprint: SPRINT_20251226_014_BINIDX
|
||||
// Task: SCANINT-20 - Performance benchmarks for binary lookup
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using StellaOps.BinaryIndex.Core.Models;
|
||||
using StellaOps.BinaryIndex.Core.Services;
|
||||
|
||||
namespace StellaOps.Bench.BinaryLookup.Benchmarks;
|
||||
|
||||
/// <summary>
|
||||
/// Performance benchmarks for binary vulnerability lookup operations.
|
||||
/// Measures single and batch lookup performance, cache efficiency,
|
||||
/// and overall throughput for scanner integration.
|
||||
/// </summary>
|
||||
[MemoryDiagnoser]
|
||||
[SimpleJob(warmupCount: 3, iterationCount: 10)]
|
||||
public class BinaryLookupBenchmarks
|
||||
{
|
||||
private readonly BinaryIdentity[] _testIdentities = GenerateTestIdentities(100);
|
||||
private readonly byte[][] _testFingerprints = GenerateTestFingerprints(100);
|
||||
|
||||
[Params(1, 10, 50, 100)]
|
||||
public int BatchSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Generate sample binary identities for benchmarking.
|
||||
/// </summary>
|
||||
private static BinaryIdentity[] GenerateTestIdentities(int count)
|
||||
{
|
||||
return Enumerable.Range(0, count).Select(i => new BinaryIdentity
|
||||
{
|
||||
Format = BinaryFormat.Elf,
|
||||
BuildId = GenerateBuildId(i),
|
||||
FileSha256 = GenerateSha256(i),
|
||||
Architecture = "x86_64",
|
||||
BinaryKey = $"libtest{i}:1.0.{i}"
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate sample fingerprints for benchmarking.
|
||||
/// </summary>
|
||||
private static byte[][] GenerateTestFingerprints(int count)
|
||||
{
|
||||
return Enumerable.Range(0, count)
|
||||
.Select(i => GenerateFingerprintBytes(i))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string GenerateBuildId(int seed)
|
||||
{
|
||||
// Generate deterministic 40-char hex build ID
|
||||
var bytes = new byte[20];
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
bytes[i] = (byte)((seed + i * 17) % 256);
|
||||
}
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string GenerateSha256(int seed)
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
for (int i = 0; i < 32; i++)
|
||||
{
|
||||
bytes[i] = (byte)((seed + i * 31) % 256);
|
||||
}
|
||||
return "sha256:" + Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static byte[] GenerateFingerprintBytes(int seed)
|
||||
{
|
||||
var bytes = new byte[64];
|
||||
for (int i = 0; i < 64; i++)
|
||||
{
|
||||
bytes[i] = (byte)((seed + i * 13) % 256);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark binary identity extraction from Build-ID.
|
||||
/// Target: < 1ms per identity
|
||||
/// </summary>
|
||||
[Benchmark(Description = "Identity extraction from Build-ID")]
|
||||
public BinaryIdentity BenchmarkIdentityExtraction()
|
||||
{
|
||||
return new BinaryIdentity
|
||||
{
|
||||
Format = BinaryFormat.Elf,
|
||||
BuildId = "8d8f09a0d7e2c1b3a5f4e6d8c0b2a4e6f8d0c2b4",
|
||||
FileSha256 = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
||||
Architecture = "x86_64",
|
||||
BinaryKey = "openssl:1.1.1w-1"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark batch identity creation for scanner integration.
|
||||
/// Target: < 10ms per batch of 100
|
||||
/// </summary>
|
||||
[Benchmark(Description = "Batch identity creation")]
|
||||
public ImmutableArray<BinaryIdentity> BenchmarkBatchIdentityCreation()
|
||||
{
|
||||
return _testIdentities.Take(BatchSize)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark fingerprint hash computation.
|
||||
/// Target: < 5ms per fingerprint
|
||||
/// </summary>
|
||||
[Benchmark(Description = "Fingerprint hash computation")]
|
||||
public string BenchmarkFingerprintHash()
|
||||
{
|
||||
var fingerprint = _testFingerprints[0];
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha256.ComputeHash(fingerprint);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark batch fingerprint comparison.
|
||||
/// Target: < 100ms for 100 comparisons
|
||||
/// </summary>
|
||||
[Benchmark(Description = "Batch fingerprint comparison")]
|
||||
public int BenchmarkBatchFingerprintComparison()
|
||||
{
|
||||
var target = _testFingerprints[0];
|
||||
var matches = 0;
|
||||
|
||||
for (int i = 0; i < BatchSize; i++)
|
||||
{
|
||||
var candidate = _testFingerprints[i];
|
||||
var similarity = ComputeHammingSimilarity(target, candidate);
|
||||
if (similarity > 0.7)
|
||||
{
|
||||
matches++;
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark binary key generation for lookup.
|
||||
/// Target: < 0.1ms per key
|
||||
/// </summary>
|
||||
[Benchmark(Description = "Binary key generation")]
|
||||
public string BenchmarkBinaryKeyGeneration()
|
||||
{
|
||||
var identity = _testIdentities[0];
|
||||
return $"{identity.BinaryKey}:{identity.Architecture}:{identity.Format}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark lookup key construction with distro.
|
||||
/// Target: < 0.1ms per key
|
||||
/// </summary>
|
||||
[Benchmark(Description = "Distro-aware lookup key")]
|
||||
public string BenchmarkDistroLookupKey()
|
||||
{
|
||||
var identity = _testIdentities[0];
|
||||
var distro = "debian";
|
||||
var release = "bookworm";
|
||||
return $"{distro}:{release}:{identity.BinaryKey}";
|
||||
}
|
||||
|
||||
private static double ComputeHammingSimilarity(byte[] a, byte[] b)
|
||||
{
|
||||
if (a.Length != b.Length) return 0.0;
|
||||
|
||||
var matching = 0;
|
||||
var total = a.Length * 8;
|
||||
|
||||
for (int i = 0; i < a.Length; i++)
|
||||
{
|
||||
var xor = (byte)(a[i] ^ b[i]);
|
||||
matching += 8 - PopCount(xor);
|
||||
}
|
||||
|
||||
return (double)matching / total;
|
||||
}
|
||||
|
||||
private static int PopCount(byte b)
|
||||
{
|
||||
var count = 0;
|
||||
while (b != 0)
|
||||
{
|
||||
count += b & 1;
|
||||
b >>= 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks for cache layer performance.
|
||||
/// </summary>
|
||||
[MemoryDiagnoser]
|
||||
[SimpleJob(warmupCount: 3, iterationCount: 10)]
|
||||
public class CacheLayerBenchmarks
|
||||
{
|
||||
private readonly Dictionary<string, object> _mockCache = new();
|
||||
private readonly string[] _testKeys;
|
||||
|
||||
public CacheLayerBenchmarks()
|
||||
{
|
||||
// Pre-populate cache with test data
|
||||
_testKeys = Enumerable.Range(0, 1000)
|
||||
.Select(i => $"binary:tenant1:buildid{i:D8}")
|
||||
.ToArray();
|
||||
|
||||
foreach (var key in _testKeys.Take(800)) // 80% pre-cached
|
||||
{
|
||||
_mockCache[key] = new { Matches = Array.Empty<object>() };
|
||||
}
|
||||
}
|
||||
|
||||
[Params(10, 100, 500)]
|
||||
public int LookupCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark cache hit performance.
|
||||
/// Target: > 80% hit rate, < 0.01ms per hit
|
||||
/// </summary>
|
||||
[Benchmark(Description = "Cache hit lookup")]
|
||||
public int BenchmarkCacheHits()
|
||||
{
|
||||
var hits = 0;
|
||||
for (int i = 0; i < LookupCount; i++)
|
||||
{
|
||||
var key = _testKeys[i % 800]; // Always hit
|
||||
if (_mockCache.ContainsKey(key))
|
||||
{
|
||||
hits++;
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark cache miss handling.
|
||||
/// Target: < 1ms per miss for key generation
|
||||
/// </summary>
|
||||
[Benchmark(Description = "Cache miss handling")]
|
||||
public int BenchmarkCacheMisses()
|
||||
{
|
||||
var misses = 0;
|
||||
for (int i = 0; i < LookupCount; i++)
|
||||
{
|
||||
var key = _testKeys[800 + (i % 200)]; // Always miss
|
||||
if (!_mockCache.ContainsKey(key))
|
||||
{
|
||||
misses++;
|
||||
}
|
||||
}
|
||||
return misses;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark mixed workload (realistic scenario).
|
||||
/// Target: > 80% hit rate overall
|
||||
/// </summary>
|
||||
[Benchmark(Description = "Mixed cache workload")]
|
||||
public (int hits, int misses) BenchmarkMixedWorkload()
|
||||
{
|
||||
var hits = 0;
|
||||
var misses = 0;
|
||||
var random = new Random(42); // Deterministic seed
|
||||
|
||||
for (int i = 0; i < LookupCount; i++)
|
||||
{
|
||||
var key = _testKeys[random.Next(_testKeys.Length)];
|
||||
if (_mockCache.ContainsKey(key))
|
||||
{
|
||||
hits++;
|
||||
}
|
||||
else
|
||||
{
|
||||
misses++;
|
||||
}
|
||||
}
|
||||
|
||||
return (hits, misses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmark cache key generation.
|
||||
/// </summary>
|
||||
[Benchmark(Description = "Cache key generation")]
|
||||
public string BenchmarkCacheKeyGeneration()
|
||||
{
|
||||
var tenant = "tenant1";
|
||||
var buildId = "8d8f09a0d7e2c1b3a5f4e6d8c0b2a4e6f8d0c2b4";
|
||||
return $"binary:{tenant}:{buildId}";
|
||||
}
|
||||
}
|
||||
20
src/__Tests/__Benchmarks/binary-lookup/Program.cs
Normal file
20
src/__Tests/__Benchmarks/binary-lookup/Program.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Program.cs
|
||||
// Sprint: SPRINT_20251226_014_BINIDX
|
||||
// Task: SCANINT-20 - Performance benchmarks for binary lookup
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using BenchmarkDotNet.Running;
|
||||
|
||||
namespace StellaOps.Bench.BinaryLookup;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for binary lookup benchmark suite.
|
||||
/// </summary>
|
||||
public class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
var summary = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
|
||||
<PackageReference Include="BenchmarkDotNet.Diagnostics.Windows" Version="0.14.0" Condition="'$(OS)' == 'Windows_NT'" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Core\StellaOps.BinaryIndex.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Fingerprints\StellaOps.BinaryIndex.Fingerprints.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
git "https://github.com/example/git-gem.git", branch: "main" do
|
||||
gem "git-gem"
|
||||
end
|
||||
|
||||
gem "httparty", "~> 0.21.0"
|
||||
|
||||
path "../vendor/path-gem" do
|
||||
gem "path-gem", "~> 2.1"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
GIT
|
||||
remote: https://github.com/example/git-gem.git
|
||||
revision: 0123456789abcdef0123456789abcdef01234567
|
||||
branch: main
|
||||
specs:
|
||||
git-gem (0.5.0)
|
||||
|
||||
PATH
|
||||
remote: vendor/plugins/path-gem
|
||||
specs:
|
||||
path-gem (2.1.3)
|
||||
rake (~> 13.0)
|
||||
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
httparty (0.21.0)
|
||||
multi_xml (~> 0.5)
|
||||
multi_xml (0.6.0)
|
||||
rake (13.1.0)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
git-gem!
|
||||
httparty (~> 0.21.0)
|
||||
path-gem (~> 2.1)!
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.10
|
||||
@@ -0,0 +1,7 @@
|
||||
require "git-gem"
|
||||
require "path-gem"
|
||||
require "httparty"
|
||||
|
||||
puts GitGem.version
|
||||
puts PathGem::Runner.new.perform
|
||||
puts HTTParty.get("https://example.invalid")
|
||||
@@ -0,0 +1,130 @@
|
||||
[
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/git-gem@0.5.0",
|
||||
"purl": "pkg:gem/git-gem@0.5.0",
|
||||
"name": "git-gem",
|
||||
"version": "0.5.0",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": true,
|
||||
"metadata": {
|
||||
"capability.net": "true",
|
||||
"declaredOnly": "true",
|
||||
"groups": "default",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"runtime.entrypoints": "app/main.rb",
|
||||
"runtime.files": "app/main.rb",
|
||||
"runtime.reasons": "require-static",
|
||||
"runtime.used": "true",
|
||||
"source": "git:https://github.com/example/git-gem.git@0123456789abcdef0123456789abcdef01234567"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "Gemfile.lock",
|
||||
"locator": "Gemfile.lock"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/httparty@0.21.0",
|
||||
"purl": "pkg:gem/httparty@0.21.0",
|
||||
"name": "httparty",
|
||||
"version": "0.21.0",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": true,
|
||||
"metadata": {
|
||||
"capability.net": "true",
|
||||
"declaredOnly": "true",
|
||||
"groups": "default",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"runtime.entrypoints": "app/main.rb",
|
||||
"runtime.files": "app/main.rb",
|
||||
"runtime.reasons": "require-static",
|
||||
"runtime.used": "true",
|
||||
"source": "https://rubygems.org/"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "Gemfile.lock",
|
||||
"locator": "Gemfile.lock"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/multi_xml@0.6.0",
|
||||
"purl": "pkg:gem/multi_xml@0.6.0",
|
||||
"name": "multi_xml",
|
||||
"version": "0.6.0",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"capability.net": "true",
|
||||
"declaredOnly": "true",
|
||||
"groups": "default",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"source": "https://rubygems.org/"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "Gemfile.lock",
|
||||
"locator": "Gemfile.lock"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/path-gem@2.1.3",
|
||||
"purl": "pkg:gem/path-gem@2.1.3",
|
||||
"name": "path-gem",
|
||||
"version": "2.1.3",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": true,
|
||||
"metadata": {
|
||||
"artifact": "vendor/cache/path-gem-2.1.3.gem",
|
||||
"capability.net": "true",
|
||||
"declaredOnly": "false",
|
||||
"groups": "default",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"runtime.entrypoints": "app/main.rb",
|
||||
"runtime.files": "app/main.rb",
|
||||
"runtime.reasons": "require-static",
|
||||
"runtime.used": "true",
|
||||
"source": "vendor-cache"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "path-gem-2.1.3.gem",
|
||||
"locator": "vendor/cache/path-gem-2.1.3.gem"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "ruby",
|
||||
"componentKey": "purl::pkg:gem/rake@13.1.0",
|
||||
"purl": "pkg:gem/rake@13.1.0",
|
||||
"name": "rake",
|
||||
"version": "13.1.0",
|
||||
"type": "gem",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"capability.net": "true",
|
||||
"declaredOnly": "true",
|
||||
"groups": "default",
|
||||
"lockfile": "Gemfile.lock",
|
||||
"source": "https://rubygems.org/"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "Gemfile.lock",
|
||||
"locator": "Gemfile.lock"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user