diff --git a/Source/Orleans.StorageProviderInterceptors/Orleans.StorageProviderInterceptors.csproj b/Source/Orleans.StorageProviderInterceptors/Orleans.StorageProviderInterceptors.csproj index 5fd758e..e940518 100644 --- a/Source/Orleans.StorageProviderInterceptors/Orleans.StorageProviderInterceptors.csproj +++ b/Source/Orleans.StorageProviderInterceptors/Orleans.StorageProviderInterceptors.csproj @@ -11,7 +11,7 @@ - + diff --git a/Tests/Orleans.StorageProviderInterceptors.Test/BasicTests.cs b/Tests/Orleans.StorageProviderInterceptors.Test/BasicTests.cs new file mode 100644 index 0000000..591409f --- /dev/null +++ b/Tests/Orleans.StorageProviderInterceptors.Test/BasicTests.cs @@ -0,0 +1,26 @@ +namespace Orleans.StorageProviderInterceptors.Test; + +using Xunit; +using Xunit.Abstractions; + +[Collection(TestClusterCollection.Name)] +public class BasicTests +{ + private readonly TestClusterFixture fixture; + private readonly ITestOutputHelper helper; + + public BasicTests(TestClusterFixture fixture, ITestOutputHelper helper) + { + this.fixture = fixture; + this.helper = helper; + } + + [Fact] + public async Task Validate_SecretStorageGrain_ReadWrite() + { + var grain = this.fixture.Cluster.GrainFactory.GetGrain("foo"); + await grain.AddOrUpdateSecret("bar", "meh"); + var secret = await grain.GetSecret("bar"); + Assert.Equal("meh", secret); + } +} diff --git a/Tests/Orleans.StorageProviderInterceptors.Test/Class1Test.cs b/Tests/Orleans.StorageProviderInterceptors.Test/Class1Test.cs deleted file mode 100644 index dedfe94..0000000 --- a/Tests/Orleans.StorageProviderInterceptors.Test/Class1Test.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Orleans.StorageProviderInterceptors.Test; - -using Xunit; - -/// -/// Class1 test -/// -public class Class1Test -{ - /// - /// Given when true - /// - [Fact] - public void Given_When_Then() => Assert.True(true); -} diff --git a/Tests/Orleans.StorageProviderInterceptors.Test/ISecretStorageGrain.cs b/Tests/Orleans.StorageProviderInterceptors.Test/ISecretStorageGrain.cs new file mode 100644 index 0000000..3c56bef --- /dev/null +++ b/Tests/Orleans.StorageProviderInterceptors.Test/ISecretStorageGrain.cs @@ -0,0 +1,7 @@ +namespace Orleans.StorageProviderInterceptors.Test; + +internal interface ISecretStorageGrain : IGrainWithStringKey +{ + Task AddOrUpdateSecret(string name, string value); + Task GetSecret(string name); +} diff --git a/Tests/Orleans.StorageProviderInterceptors.Test/Orleans.StorageProviderInterceptors.Test.csproj b/Tests/Orleans.StorageProviderInterceptors.Test/Orleans.StorageProviderInterceptors.Test.csproj index 59d8f01..37a4641 100644 --- a/Tests/Orleans.StorageProviderInterceptors.Test/Orleans.StorageProviderInterceptors.Test.csproj +++ b/Tests/Orleans.StorageProviderInterceptors.Test/Orleans.StorageProviderInterceptors.Test.csproj @@ -4,6 +4,11 @@ net7.0 true + + + 1701;1702;NU5105;1591 + false + @@ -27,6 +32,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/Tests/Orleans.StorageProviderInterceptors.Test/SecretStorageGrain.cs b/Tests/Orleans.StorageProviderInterceptors.Test/SecretStorageGrain.cs new file mode 100644 index 0000000..4e8318b --- /dev/null +++ b/Tests/Orleans.StorageProviderInterceptors.Test/SecretStorageGrain.cs @@ -0,0 +1,22 @@ +namespace Orleans.StorageProviderInterceptors.Test; + +using Abstractions; +using Runtime; + +internal sealed class SecretStorageGrain : Grain, ISecretStorageGrain +{ + private readonly IPersistentState> secrets; + + public SecretStorageGrain( + [StorageInterceptor( + TestSiloConfigurations.StorageName, "secretsState")] + IPersistentState> state) => this.secrets = state; + + public async Task AddOrUpdateSecret(string name, string value) + { + this.secrets.State[name] = value; + await this.secrets.WriteStateAsync(); + } + + public Task GetSecret(string name) => Task.FromResult(this.secrets.State[name]); +} diff --git a/Tests/Orleans.StorageProviderInterceptors.Test/TestCluster.cs b/Tests/Orleans.StorageProviderInterceptors.Test/TestCluster.cs new file mode 100644 index 0000000..a291c90 --- /dev/null +++ b/Tests/Orleans.StorageProviderInterceptors.Test/TestCluster.cs @@ -0,0 +1,36 @@ +namespace Orleans.StorageProviderInterceptors.Test; + +using System; +using Microsoft.Extensions.Configuration; +using Orleans; +using Orleans.TestingHost; + +public sealed class TestClusterFixture : IDisposable +{ + public string TestClusterId = new Guid().ToString(); + + public Orleans.TestingHost.TestCluster Cluster { get; } + + public IClusterClient? ClusterClient { get; } + + + public TestClusterFixture() + { + var builder = new TestClusterBuilder(); + + builder.ConfigureHostConfiguration(config => config.AddInMemoryCollection( + new Dictionary + { + { nameof(this.TestClusterId), this.TestClusterId } + }!)); + + builder.AddSiloBuilderConfigurator(); + + this.Cluster = builder.Build(); + this.Cluster.Deploy(); + + this.ClusterClient = (IClusterClient)this.Cluster.ServiceProvider.GetService(typeof(IClusterClient))!; + } + + public void Dispose() => this.Cluster.StopAllSilos(); +} diff --git a/Tests/Orleans.StorageProviderInterceptors.Test/TestClusterCollection.cs b/Tests/Orleans.StorageProviderInterceptors.Test/TestClusterCollection.cs new file mode 100644 index 0000000..e969325 --- /dev/null +++ b/Tests/Orleans.StorageProviderInterceptors.Test/TestClusterCollection.cs @@ -0,0 +1,11 @@ +namespace Orleans.StorageProviderInterceptors.Test; + +using Xunit; + +// Important note: Fixtures can be shared across assemblies, but collection definitions must be in the same assembly as the test that uses them. +// https://xunit.net/docs/shared-context +[CollectionDefinition(Name)] +public class TestClusterCollection : ICollectionFixture +{ + public const string Name = "TestClusterCollection"; +} diff --git a/Tests/Orleans.StorageProviderInterceptors.Test/TestSiloConfigurations.cs b/Tests/Orleans.StorageProviderInterceptors.Test/TestSiloConfigurations.cs new file mode 100644 index 0000000..72776f5 --- /dev/null +++ b/Tests/Orleans.StorageProviderInterceptors.Test/TestSiloConfigurations.cs @@ -0,0 +1,128 @@ +namespace Orleans.StorageProviderInterceptors.Test; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Orleans.Streams; +using Orleans.TestingHost; + +/// +public class TestSiloConfigurations : ISiloConfigurator, IHostConfigurator +{ + internal const string StorageName = "SecretsStorage"; + + /// + public void Configure(ISiloBuilder siloBuilder) + { + siloBuilder.UseInMemoryReminderService(); + siloBuilder.AddMemoryStreams("Default", config => + { + config.ConfigureStreamPubSub(StreamPubSubType.ImplicitOnly); + config.ConfigureCacheEviction(builder => builder.Configure(options => + { + options.DataMinTimeInCache = TimeSpan.FromMinutes(2); + options.DataMaxAgeInCache = TimeSpan.FromMinutes(10); + options.MetadataMinTimeInCache = TimeSpan.FromMinutes(60); + })); + }); + siloBuilder + .AddStorageInterceptors() + .AddMemoryGrainStorage(StorageName) + .AddStorageInterceptors() + .UseGenericStorageInterceptor>( + StorageName, "secretsState", c => + { + c.OnBeforeWriteStateFunc = (grainActivationContext, currentState) => + { + var unencryptedValues = new Dictionary(currentState.State.Count); + Console.WriteLine( + $"OnBeforeWriteState: {grainActivationContext}: Count Is {currentState.State.Count}"); + foreach (var (key, value) in currentState.State) + { + Console.WriteLine($"Intercepted: {key}: {value}"); + + // Save the original state to return to the grain + unencryptedValues.Add(key, value); + + // Encrypt the data + currentState.State[key] = currentState.State[key].Replace('e', '3'); + } + + return ValueTask.FromResult((false, (object?)unencryptedValues)); + }; + + c.OnAfterWriteStateFunc = (grainActivationContext, currentState, sharedState) => + { + var unencryptedValues = (Dictionary)sharedState!; + Console.WriteLine( + $"OnAfterWriteState: {grainActivationContext}: Count Is {currentState.State.Count}"); + foreach (var (key, value) in currentState.State) + { + Console.WriteLine($"What was actually persisted: {key}: {value}"); + + currentState.State[key] = unencryptedValues[key]; + Console.WriteLine($"What will be returned to grain: {key}: {unencryptedValues[key]}"); + } + + return ValueTask.CompletedTask; + }; + + c.OnBeforeReadStateAsync = (grainActivationContext, currentState) => + { + Console.WriteLine( + $"OnBeforeReadState: {grainActivationContext}: Count Is {currentState.State.Count}"); + + var unencryptedValues = new Dictionary(currentState.State.Count); + foreach (var (key, value) in currentState.State) + { + Console.WriteLine($"Unencrypted Values: {key}: {value}"); + + // Save the original state to return to the grain + unencryptedValues.Add(key, value); + } + + return ValueTask.FromResult((false, (object?)unencryptedValues)); + }; + + c.OnAfterReadStateFunc = (grainActivationContext, currentState, sharedState) => + { + var unencryptedValues = (Dictionary)sharedState!; + if (!currentState.RecordExists) + { + return ValueTask.CompletedTask; + } + + var list = sharedState as List; + Console.WriteLine( + $"OnAfterReadState: {grainActivationContext}: Count Is {currentState.State.Count}"); + + foreach (var (key, value) in currentState.State) + { + Console.WriteLine($"Encrypted Values: {key}: {value}"); + + // Decrypt the data + currentState.State[key] = currentState.State[key].Replace('3', 'e'); + } + + return ValueTask.CompletedTask; + }; + }); + } + + /// + public virtual void Configure(IHostBuilder hostBuilder) + { + AppContext.SetSwitch("System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization", true); + + hostBuilder.ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + }); + + hostBuilder.ConfigureServices((context, services) => + { + // grab the cluster id that owns this silo + var clusterId = context.Configuration["TestClusterId"]; + }); + } +}