using Microsoft.Extensions.Options; using StellaOps.Telemetry.Federation.Privacy; namespace StellaOps.Telemetry.Federation.Tests; public sealed class PrivacyBudgetTrackerTests { private static IOptions DefaultOptions( double epsilon = 1.0, TimeSpan? resetPeriod = null) { return Options.Create(new FederatedTelemetryOptions { EpsilonBudget = epsilon, BudgetResetPeriod = resetPeriod ?? TimeSpan.FromHours(24) }); } [Fact] public void Initial_budget_equals_total() { var tracker = new PrivacyBudgetTracker(DefaultOptions(epsilon: 2.0)); Assert.Equal(2.0, tracker.TotalBudget); Assert.Equal(2.0, tracker.RemainingEpsilon); Assert.False(tracker.IsBudgetExhausted); } [Fact] public void TrySpend_reduces_remaining_epsilon() { var tracker = new PrivacyBudgetTracker(DefaultOptions(epsilon: 1.0)); Assert.True(tracker.TrySpend(0.3)); Assert.Equal(0.7, tracker.RemainingEpsilon, precision: 10); } [Fact] public void TrySpend_rejects_when_budget_exhausted() { var tracker = new PrivacyBudgetTracker(DefaultOptions(epsilon: 0.5)); Assert.True(tracker.TrySpend(0.5)); Assert.False(tracker.TrySpend(0.1)); Assert.True(tracker.IsBudgetExhausted); } [Fact] public void TrySpend_rejects_negative_or_zero_epsilon() { var tracker = new PrivacyBudgetTracker(DefaultOptions()); Assert.False(tracker.TrySpend(0)); Assert.False(tracker.TrySpend(-0.5)); } [Fact] public void Reset_restores_full_budget() { var tracker = new PrivacyBudgetTracker(DefaultOptions(epsilon: 1.0)); tracker.TrySpend(0.8); Assert.Equal(0.2, tracker.RemainingEpsilon, precision: 10); tracker.Reset(); Assert.Equal(1.0, tracker.RemainingEpsilon); Assert.False(tracker.IsBudgetExhausted); } [Fact] public void Snapshot_tracks_queries_and_suppressed_counts() { var tracker = new PrivacyBudgetTracker(DefaultOptions(epsilon: 0.5)); tracker.TrySpend(0.2); // success tracker.TrySpend(0.2); // success tracker.TrySpend(0.2); // fails — over budget var snapshot = tracker.GetSnapshot(); Assert.Equal(2, snapshot.QueriesThisPeriod); Assert.Equal(1, snapshot.SuppressedThisPeriod); Assert.True(snapshot.Exhausted); } [Fact] public void LaplacianNoise_produces_finite_values() { var rng = new Random(42); for (int i = 0; i < 1000; i++) { var noise = PrivacyBudgetTracker.LaplacianNoise(1.0, 0.5, rng); Assert.True(double.IsFinite(noise)); } } [Fact] public void LaplacianNoise_is_deterministic_with_fixed_seed() { var rng1 = new Random(12345); var rng2 = new Random(12345); var noise1 = PrivacyBudgetTracker.LaplacianNoise(1.0, 1.0, rng1); var noise2 = PrivacyBudgetTracker.LaplacianNoise(1.0, 1.0, rng2); Assert.Equal(noise1, noise2); } [Fact] public void LaplacianNoise_scales_with_sensitivity_and_epsilon() { var rng = new Random(42); var samples = Enumerable.Range(0, 10000) .Select(_ => PrivacyBudgetTracker.LaplacianNoise(1.0, 1.0, rng)) .ToList(); // Mean should be approximately 0 for large sample var mean = samples.Average(); Assert.True(Math.Abs(mean) < 0.1, $"Mean {mean} too far from 0"); } }