diff --git a/eng/.docsettings.yml b/eng/.docsettings.yml index 4a18a0522ef7..237e65e94514 100644 --- a/eng/.docsettings.yml +++ b/eng/.docsettings.yml @@ -70,6 +70,7 @@ known_presence_issues: - ['sdk/keyvault','#5499'] - ['sdk/eventhub','#5499'] - ['sdk/attestation/Microsoft.Azure.Attestation','#5499'] + - ['sdk/storage/Azure.AspNetCore.DataProtection.Blobs','#9960'] - ['sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration', '#9939'] - ['sdk/keyvault/Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection','#9955'] diff --git a/eng/Packages.Data.props b/eng/Packages.Data.props index 89ff91ab7170..7594d940b23f 100755 --- a/eng/Packages.Data.props +++ b/eng/Packages.Data.props @@ -23,8 +23,6 @@ - - @@ -49,12 +47,6 @@ - - - - - - @@ -114,20 +106,45 @@ - + + + + + + + + + + + + + + + + - + - - - - - + + + + + + + + + + + + + + + diff --git a/sdk/core/Microsoft.Extensions.Azure/src/Microsoft.Extensions.Azure.csproj b/sdk/core/Microsoft.Extensions.Azure/src/Microsoft.Extensions.Azure.csproj index 230dd29dead2..7f8a1d6da3ff 100644 --- a/sdk/core/Microsoft.Extensions.Azure/src/Microsoft.Extensions.Azure.csproj +++ b/sdk/core/Microsoft.Extensions.Azure/src/Microsoft.Extensions.Azure.csproj @@ -12,6 +12,7 @@ true false + true diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection/src/Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection.csproj b/sdk/keyvault/Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection/src/Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection.csproj index 5de460712cb1..4495c78b982a 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection/src/Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection.csproj +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection/src/Azure.Security.KeyVault.Secrets.AspNetCore.DataProtection.csproj @@ -5,6 +5,7 @@ aspnetcore;dataprotection;azure;keyvault 1.0.0-preview.1 false + true diff --git a/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/Azure.Security.KeyVault.Secrets.Extensions.Configuration.csproj b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/Azure.Security.KeyVault.Secrets.Extensions.Configuration.csproj index 0f741f5a3862..42daed390afe 100644 --- a/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/Azure.Security.KeyVault.Secrets.Extensions.Configuration.csproj +++ b/sdk/keyvault/Azure.Security.KeyVault.Secrets.Extensions.Configuration/src/Azure.Security.KeyVault.Secrets.Extensions.Configuration.csproj @@ -6,6 +6,7 @@ $(PackageTags);azure;keyvault 1.0.0-preview.1 false + true diff --git a/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/api/Azure.AspNetCore.DataProtection.Blobs.netstandard2.0.cs b/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/api/Azure.AspNetCore.DataProtection.Blobs.netstandard2.0.cs new file mode 100644 index 000000000000..f23bb8f7b2b8 --- /dev/null +++ b/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/api/Azure.AspNetCore.DataProtection.Blobs.netstandard2.0.cs @@ -0,0 +1,18 @@ +namespace Azure.AspNetCore.DataProtection.Blobs +{ + public sealed partial class AzureBlobXmlRepository : Microsoft.AspNetCore.DataProtection.Repositories.IXmlRepository + { + public AzureBlobXmlRepository(Azure.Storage.Blobs.BlobClient blobClient) { } + public System.Collections.Generic.IReadOnlyCollection GetAllElements() { throw null; } + public void StoreElement(System.Xml.Linq.XElement element, string friendlyName) { } + } +} +namespace Microsoft.AspNetCore.DataProtection +{ + public static partial class AzureStorageBlobDataProtectionBuilderExtensions + { + public static Microsoft.AspNetCore.DataProtection.IDataProtectionBuilder PersistKeysToAzureBlobStorage(this Microsoft.AspNetCore.DataProtection.IDataProtectionBuilder builder, Azure.Storage.Blobs.BlobClient blobClient) { throw null; } + public static Microsoft.AspNetCore.DataProtection.IDataProtectionBuilder PersistKeysToAzureBlobStorage(this Microsoft.AspNetCore.DataProtection.IDataProtectionBuilder builder, System.Uri blobUri) { throw null; } + public static Microsoft.AspNetCore.DataProtection.IDataProtectionBuilder PersistKeysToAzureBlobStorage(this Microsoft.AspNetCore.DataProtection.IDataProtectionBuilder builder, System.Uri blobUri, Azure.Core.TokenCredential tokenCredential) { throw null; } + } +} diff --git a/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/src/Azure.AspNetCore.DataProtection.Blobs.csproj b/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/src/Azure.AspNetCore.DataProtection.Blobs.csproj new file mode 100644 index 000000000000..61b236c45604 --- /dev/null +++ b/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/src/Azure.AspNetCore.DataProtection.Blobs.csproj @@ -0,0 +1,18 @@ + + + + $(RequiredTargetFrameworks) + Microsoft Azure Blob storage support as key store (https://docs.microsoft.com/en-us/aspnet/core/security/data-protection/implementation/key-storage-providers). + aspnetcore;dataprotection;azure;blob;key store + 1.0.0-preview.1 + false + true + + + + + + + + + diff --git a/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/src/AzureBlobXmlRepository.cs b/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/src/AzureBlobXmlRepository.cs new file mode 100644 index 000000000000..ad6d91322de2 --- /dev/null +++ b/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/src/AzureBlobXmlRepository.cs @@ -0,0 +1,266 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Microsoft.AspNetCore.DataProtection.Repositories; + +#pragma warning disable AZC0001 // +namespace Azure.AspNetCore.DataProtection.Blobs +#pragma warning restore +{ + /// + /// An which is backed by Azure Blob Storage. + /// + /// + /// Instances of this type are thread-safe. + /// + public sealed class AzureBlobXmlRepository : IXmlRepository + { + private const int ConflictMaxRetries = 5; + private static readonly TimeSpan ConflictBackoffPeriod = TimeSpan.FromMilliseconds(200); + private static readonly XName RepositoryElementName = "repository"; + private static BlobHttpHeaders _blobHttpHeaders = new BlobHttpHeaders() { ContentType = "application/xml; charset=utf-8" }; + + private readonly Random _random; + private BlobData _cachedBlobData; + private readonly BlobClient _blobClient; + + /// + /// Creates a new instance of the . + /// + /// A that is connected to the blob we are reading from and writing to. + public AzureBlobXmlRepository(BlobClient blobClient) + { + _random = new Random(); + _blobClient = blobClient; + } + + /// + public IReadOnlyCollection GetAllElements() + { + // Shunt the work onto a ThreadPool thread so that it's independent of any + // existing sync context or other potentially deadlock-causing items. + + var elements = Task.Run(() => GetAllElementsAsync()).GetAwaiter().GetResult(); + return new ReadOnlyCollection(elements); + } + + /// + public void StoreElement(XElement element, string friendlyName) + { + if (element == null) + { + throw new ArgumentNullException(nameof(element)); + } + + // Shunt the work onto a ThreadPool thread so that it's independent of any + // existing sync context or other potentially deadlock-causing items. + + Task.Run(() => StoreElementAsync(element)).GetAwaiter().GetResult(); + } + + private XDocument CreateDocumentFromBlob(byte[] blob) + { + using (var memoryStream = new MemoryStream(blob)) + { + var xmlReaderSettings = new XmlReaderSettings() + { + DtdProcessing = DtdProcessing.Prohibit, IgnoreProcessingInstructions = true + }; + + using (var xmlReader = XmlReader.Create(memoryStream, xmlReaderSettings)) + { + return XDocument.Load(xmlReader); + } + } + } + + private async Task> GetAllElementsAsync() + { + var data = await GetLatestDataAsync().ConfigureAwait(false); + + if (data == null || data.BlobContents.Length == 0) + { + // no data in blob storage + return Array.Empty(); + } + + // The document will look like this: + // + // + // + // + // ... + // + // + // We want to return the first-level child elements to our caller. + + var doc = CreateDocumentFromBlob(data.BlobContents); + return doc.Root.Elements().ToList(); + } + + private async Task GetLatestDataAsync() + { + // Set the appropriate AccessCondition based on what we believe the latest + // file contents to be, then make the request. + + var latestCachedData = Volatile.Read(ref _cachedBlobData); // local ref so field isn't mutated under our feet + var requestCondition = (latestCachedData != null) + ? new BlobRequestConditions() { IfNoneMatch = latestCachedData.ETag } + : null; + + try + { + using (var memoryStream = new MemoryStream()) + { + var response = await _blobClient.DownloadToAsync( + destination: memoryStream, + conditions: requestCondition).ConfigureAwait(false); + + // At this point, our original cache either didn't exist or was outdated. + // We'll update it now and return the updated value + latestCachedData = new BlobData() + { + BlobContents = memoryStream.ToArray(), + ETag = response.Headers.ETag + }; + + } + Volatile.Write(ref _cachedBlobData, latestCachedData); + } + catch (RequestFailedException ex) when (ex.Status == 304) + { + // 304 Not Modified + // Thrown when we already have the latest cached data. + // This isn't an error; we'll return our cached copy of the data. + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + // 404 Not Found + // Thrown when no file exists in storage. + // This isn't an error; we'll delete our cached copy of data. + + latestCachedData = null; + Volatile.Write(ref _cachedBlobData, latestCachedData); + } + + return latestCachedData; + } + + private int GetRandomizedBackoffPeriod() + { + // returns a TimeSpan in the range [0.8, 1.0) * ConflictBackoffPeriod + // not used for crypto purposes + var multiplier = 0.8 + (_random.NextDouble() * 0.2); + return (int) (multiplier * ConflictBackoffPeriod.Ticks); + } + + private async Task StoreElementAsync(XElement element) + { + // holds the last error in case we need to rethrow it + ExceptionDispatchInfo lastError = null; + + for (var i = 0; i < ConflictMaxRetries; i++) + { + if (i > 1) + { + // If multiple conflicts occurred, wait a small period of time before retrying + // the operation so that other writers can make forward progress. + await Task.Delay(GetRandomizedBackoffPeriod()).ConfigureAwait(false); + } + + if (i > 0) + { + // If at least one conflict occurred, make sure we have an up-to-date + // view of the blob contents. + await GetLatestDataAsync().ConfigureAwait(false); + } + + // Merge the new element into the document. If no document exists, + // create a new default document and inject this element into it. + + var latestData = Volatile.Read(ref _cachedBlobData); + var doc = (latestData != null) + ? CreateDocumentFromBlob(latestData.BlobContents) + : new XDocument(new XElement(RepositoryElementName)); + doc.Root.Add(element); + + // Turn this document back into a byte[]. + + var serializedDoc = new MemoryStream(); + doc.Save(serializedDoc, SaveOptions.DisableFormatting); + serializedDoc.Position = 0; + + // Generate the appropriate precondition header based on whether or not + // we believe data already exists in storage. + + BlobRequestConditions requestConditions; + if (latestData != null) + { + requestConditions = new BlobRequestConditions() { IfMatch = latestData.ETag }; + } + else + { + requestConditions = new BlobRequestConditions() { IfNoneMatch = ETag.All }; + } + + try + { + // Send the request up to the server. + var response = await _blobClient.UploadAsync( + serializedDoc, + httpHeaders: _blobHttpHeaders, + conditions: requestConditions).ConfigureAwait(false); + + // If we got this far, success! + // We can update the cached view of the remote contents. + + Volatile.Write(ref _cachedBlobData, new BlobData() + { + BlobContents = serializedDoc.ToArray(), + ETag = response.Value.ETag // was updated by Upload routine + }); + + return; + } + catch (RequestFailedException ex) + when (ex.Status == 409 || ex.Status == 412) + { + // 409 Conflict + // This error is rare but can be thrown in very special circumstances, + // such as if the blob in the process of being created. We treat it + // as equivalent to 412 for the purposes of retry logic. + + // 412 Precondition Failed + // We'll get this error if another writer updated the repository and we + // have an outdated view of its contents. If this occurs, we'll just + // refresh our view of the remote contents and try again up to the max + // retry limit. + + lastError = ExceptionDispatchInfo.Capture(ex); + } + } + + // if we got this far, something went awry + lastError.Throw(); + } + + private sealed class BlobData + { + internal byte[] BlobContents; + internal ETag? ETag; + } + } +} diff --git a/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/src/AzureStorageBlobDataProtectionBuilderExtensions.cs b/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/src/AzureStorageBlobDataProtectionBuilderExtensions.cs new file mode 100644 index 000000000000..a4d732274f35 --- /dev/null +++ b/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/src/AzureStorageBlobDataProtectionBuilderExtensions.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using Azure.Core; +using Azure.Identity; +using Azure.Storage.Blobs; +using Azure.AspNetCore.DataProtection.Blobs; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using Microsoft.Extensions.DependencyInjection; + +#pragma warning disable AZC0001 // Extension methods have to be in the correct namespace to appear in intellisense. +namespace Microsoft.AspNetCore.DataProtection +#pragma warning disable +{ + /// + /// Contains Azure-specific extension methods for modifying a + /// . + /// + public static class AzureStorageBlobDataProtectionBuilderExtensions + { + /// + /// Configures the data protection system to persist keys to the specified path + /// in Azure Blob Storage. + /// + /// The builder instance to modify. + /// The full URI where the key file should be stored. + /// The URI must contain the SAS token as a query string parameter. + /// The value . + /// + /// The container referenced by must already exist. + /// + public static IDataProtectionBuilder PersistKeysToAzureBlobStorage(this IDataProtectionBuilder builder, Uri blobUri) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (blobUri == null) + { + throw new ArgumentNullException(nameof(blobUri)); + } + + var uriBuilder = new BlobUriBuilder(blobUri); + BlobClient client; + + // The SAS token is present in the query string. + if (uriBuilder.Sas == null) + { + client = new BlobClient(blobUri, new DefaultAzureCredential()); + } + else + { + client = new BlobClient(blobUri); + } + + return PersistKeystoAzureBlobStorageInternal(builder, client); + } + + /// + /// Configures the data protection system to persist keys to the specified path + /// in Azure Blob Storage. + /// + /// The builder instance to modify. + /// The full URI where the key file should be stored. + /// The URI must contain the SAS token as a query string parameter. + /// The credentials to connect to the blob. + /// The value . + /// + /// The container referenced by must already exist. + /// + public static IDataProtectionBuilder PersistKeysToAzureBlobStorage(this IDataProtectionBuilder builder, Uri blobUri, TokenCredential tokenCredential) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (blobUri == null) + { + throw new ArgumentNullException(nameof(blobUri)); + } + if (tokenCredential == null) + { + throw new ArgumentNullException(nameof(tokenCredential)); + } + + var client = new BlobClient(blobUri, tokenCredential); + + return PersistKeystoAzureBlobStorageInternal(builder, client); + } + + /// + /// Configures the data protection system to persist keys to the specified path + /// in Azure Blob Storage. + /// + /// The builder instance to modify. + /// The in which the + /// key file should be stored. + /// The value . + /// + /// The blob referenced by must already exist. + /// + public static IDataProtectionBuilder PersistKeysToAzureBlobStorage(this IDataProtectionBuilder builder, BlobClient blobClient) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (blobClient == null) + { + throw new ArgumentNullException(nameof(blobClient)); + } + return PersistKeystoAzureBlobStorageInternal(builder, blobClient); + } + + private static IDataProtectionBuilder PersistKeystoAzureBlobStorageInternal(IDataProtectionBuilder builder, BlobClient blobClient) + { + builder.Services.Configure(options => + { + options.XmlRepository = new AzureBlobXmlRepository(blobClient); + }); + return builder; + } + } +} diff --git a/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/tests/Azure.AspNetCore.DataProtection.Blobs.Tests.csproj b/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/tests/Azure.AspNetCore.DataProtection.Blobs.Tests.csproj new file mode 100644 index 000000000000..6b9b1b0686c6 --- /dev/null +++ b/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/tests/Azure.AspNetCore.DataProtection.Blobs.Tests.csproj @@ -0,0 +1,20 @@ + + + + $(RequiredTargetFrameworks) + true + + + + + + + + + + + + + + + diff --git a/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/tests/AzureBlobXmlRepositoryTests.cs b/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/tests/AzureBlobXmlRepositoryTests.cs new file mode 100644 index 000000000000..a3d2a9c3afa9 --- /dev/null +++ b/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/tests/AzureBlobXmlRepositoryTests.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Azure; +using Azure.Core; +using Azure.Core.Testing; +using Azure.Storage; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Moq; +using NUnit.Framework; + +namespace Azure.AspNetCore.DataProtection.Blobs.Tests +{ + public class AzureBlobXmlRepositoryTests + { + [Test] + public void StoreCreatesBlobWhenNotExist() + { + BlobRequestConditions uploadConditions = null; + byte[] bytes = null; + string contentType = null; + + var mock = new Mock(); + + mock.Setup(c => c.UploadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(async (Stream strm, BlobHttpHeaders headers, IDictionary metaData, BlobRequestConditions conditions, IProgress progress, AccessTier? access, StorageTransferOptions transfer, CancellationToken token) => + { + using var memoryStream = new MemoryStream(); + strm.CopyTo(memoryStream); + bytes = memoryStream.ToArray(); + uploadConditions = conditions; + contentType = headers?.ContentType; + + await Task.Yield(); + + var mockResponse = new Mock>(); + var blobContentInfo = BlobsModelFactory.BlobContentInfo(ETag.All, DateTimeOffset.Now.AddDays(-1), Array.Empty(), "", 1); + + mockResponse.Setup(c => c.Value).Returns(blobContentInfo); + return mockResponse.Object; + }); + + var repository = new AzureBlobXmlRepository(mock.Object); + repository.StoreElement(new XElement("Element"), null); + + Assert.AreEqual("*", uploadConditions.IfNoneMatch.ToString()); + Assert.AreEqual("application/xml; charset=utf-8", contentType); + var element = ""; + + Assert.AreEqual(bytes, GetEnvelopedContent(element)); + } + + [Test] + public void StoreUpdatesWhenExistsAndNewerExists() + { + byte[] bytes = null; + + var mock = new Mock(); + + mock.Setup(c => c.DownloadToAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(async (Stream target, BlobRequestConditions conditions, StorageTransferOptions options, CancellationToken token) => + { + var data = GetEnvelopedContent(""); + await target.WriteAsync(data, 0, data.Length); + + var response = new MockResponse(200); + response.AddHeader(new HttpHeader("ETag", "*")); + return response; + }) + .Verifiable(); + + mock.Setup(c => c.UploadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.Is((BlobRequestConditions conditions) => conditions.IfNoneMatch == ETag.All), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Throws(new RequestFailedException(status: 412, message: "")) + .Verifiable(); + + mock.Setup(c => c.UploadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.Is((BlobRequestConditions conditions) => conditions.IfNoneMatch != ETag.All), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(async (Stream strm, BlobHttpHeaders headers, IDictionary metaData, BlobRequestConditions conditions, IProgress progress, AccessTier? access, StorageTransferOptions transfer, CancellationToken token) => + { + using var memoryStream = new MemoryStream(); + strm.CopyTo(memoryStream); + bytes = memoryStream.ToArray(); + + await Task.Yield(); + + var mockResponse = new Mock>(); + var blobContentInfo = BlobsModelFactory.BlobContentInfo(ETag.All, DateTimeOffset.Now.AddDays(-1), Array.Empty(), "", 1); + mockResponse.Setup(c => c.Value).Returns(blobContentInfo); + return mockResponse.Object; + }) + .Verifiable(); + + var repository = new AzureBlobXmlRepository(mock.Object); + repository.StoreElement(new XElement("Element2"), null); + + mock.Verify(); + Assert.AreEqual(bytes, GetEnvelopedContent("")); + } + + private static byte[] GetEnvelopedContent(string element) + { + return Encoding.UTF8.GetBytes($"{element}"); + } + } +} diff --git a/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/tests/AzureDataProtectionBuilderExtensionsTest.cs b/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/tests/AzureDataProtectionBuilderExtensionsTest.cs new file mode 100644 index 000000000000..ee59a0a170a0 --- /dev/null +++ b/sdk/storage/Azure.AspNetCore.DataProtection.Blobs/tests/AzureDataProtectionBuilderExtensionsTest.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using Azure.Storage.Blobs; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NUnit.Framework; + +namespace Azure.AspNetCore.DataProtection.Blobs.Tests +{ + public class AzureDataProtectionBuilderExtensionsTest + { + [Test] + public void PersistKeysToAzureBlobStorage_UsesAzureBlobXmlRepository() + { + // Arrange + var client = new BlobClient(new Uri("http://www.example.com")); + var serviceCollection = new ServiceCollection(); + var builder = serviceCollection.AddDataProtection(); + + // Act + builder.PersistKeysToAzureBlobStorage(client); + var services = serviceCollection.BuildServiceProvider(); + + // Assert + var options = services.GetRequiredService>(); + Assert.IsInstanceOf(options.Value.XmlRepository); + } + } +} diff --git a/sdk/storage/Azure.Storage.sln b/sdk/storage/Azure.Storage.sln index 612768e7156e..597f73a984c8 100644 --- a/sdk/storage/Azure.Storage.sln +++ b/sdk/storage/Azure.Storage.sln @@ -113,6 +113,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Storage.Blobs.Batch.T EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApiCompat", "..\..\eng\ApiCompat\ApiCompat.csproj", "{16260507-C87E-44D2-883F-8E338B7A519F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.AspNetCore.DataProtection.Blobs", "Azure.AspNetCore.DataProtection.Blobs\src\Azure.AspNetCore.DataProtection.Blobs.csproj", "{E6B80592-AF36-4D07-8655-963A65103FDF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.AspNetCore.DataProtection.Blobs.Tests", "Azure.AspNetCore.DataProtection.Blobs\tests\Azure.AspNetCore.DataProtection.Blobs.Tests.csproj", "{0DB3D27F-8C6F-4CFE-9059-891F63EFBF4E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -203,6 +207,14 @@ Global {16260507-C87E-44D2-883F-8E338B7A519F}.Debug|Any CPU.Build.0 = Debug|Any CPU {16260507-C87E-44D2-883F-8E338B7A519F}.Release|Any CPU.ActiveCfg = Release|Any CPU {16260507-C87E-44D2-883F-8E338B7A519F}.Release|Any CPU.Build.0 = Release|Any CPU + {E6B80592-AF36-4D07-8655-963A65103FDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6B80592-AF36-4D07-8655-963A65103FDF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6B80592-AF36-4D07-8655-963A65103FDF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6B80592-AF36-4D07-8655-963A65103FDF}.Release|Any CPU.Build.0 = Release|Any CPU + {0DB3D27F-8C6F-4CFE-9059-891F63EFBF4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0DB3D27F-8C6F-4CFE-9059-891F63EFBF4E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0DB3D27F-8C6F-4CFE-9059-891F63EFBF4E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0DB3D27F-8C6F-4CFE-9059-891F63EFBF4E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE