diff --git a/src/Client/Backblaze.Client.csproj b/src/Client/Backblaze.Client.csproj index 640a322..e4f968c 100644 --- a/src/Client/Backblaze.Client.csproj +++ b/src/Client/Backblaze.Client.csproj @@ -10,7 +10,7 @@ Microcompiler Bytewizer Inc. Backblaze B2 Cloud Storage - 7.1 + 8.0 Bytewizer.Backblaze 1.1.0 $(VersionPrefix)-$(VersionSuffix) diff --git a/src/Client/Client/ApiRest.Endpoints.cs b/src/Client/Client/ApiRest.Endpoints.cs index e7a91a8..d82c08c 100644 --- a/src/Client/Client/ApiRest.Endpoints.cs +++ b/src/Client/Client/ApiRest.Endpoints.cs @@ -13,6 +13,8 @@ using Bytewizer.Backblaze.Models; using Bytewizer.Backblaze.Extensions; +using Bytewizer.Backblaze.Client.Internal; + namespace Bytewizer.Backblaze.Client { public abstract partial class ApiRest : DisposableObject @@ -47,7 +49,9 @@ public async Task> AuthorizeAccountAync using (var results = await _policy.InvokeClient.ExecuteAsync(async () => { return await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); })) { - return await HandleResponseAsync(results).ConfigureAwait(false); + var rawResult = await HandleResponseAsync(results).ConfigureAwait(false); + + return new ApiResults(rawResult.HttpResponse, rawResult.Response.ToAuthorizedAccountResponse()); } } diff --git a/src/Client/Client/Internal/AllowedRaw.cs b/src/Client/Client/Internal/AllowedRaw.cs new file mode 100644 index 0000000..d96d3d4 --- /dev/null +++ b/src/Client/Client/Internal/AllowedRaw.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +using Newtonsoft.Json; + +using Bytewizer.Backblaze.Models; + +namespace Bytewizer.Backblaze.Client.Internal +{ + /// + /// Represents information related to an allowed authorization. + /// + [DebuggerDisplay("{DebuggerDisplay, nq}")] + internal class AllowedRaw + { + /// + /// Gets or sets a list of allowed. + /// + [JsonProperty(Required = Required.Always)] + public List Capabilities { get; set; } + + /// + /// Gets or sets restricted access only to this bucket id. + /// + public string BucketId { get; set; } + + /// + /// When bucket id is set and it is a valid bucket that has not been deleted this field is set to the name of the + /// bucket. It's possible that bucket id is set to a bucket that no longer exists in which case this field will be + /// null. It's also null when bucket id is null. + /// + public string BucketName { get; set; } + + /// + /// Gets or sets restricted access to files whose names start with the prefix. + /// + public string NamePrefix { get; set; } + + /// + /// Debugger display for this object. + /// + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private string DebuggerDisplay + { + get { return $"{{{nameof(Capabilities)}: {string.Join(", ", Capabilities)}, {nameof(NamePrefix)}: {NamePrefix}}}"; } + } + + /// + /// Translates this instance to an instance of , + /// parsing capabilities into the and + /// properties. + /// + /// An instance of . + public Allowed ParseCapabilities() + { + Allowed parsed = new Allowed(); + + parsed.BucketId = this.BucketId; + parsed.BucketName = this.BucketName; + parsed.NamePrefix = this.NamePrefix; + + parsed.Capabilities = new Capabilities(); + parsed.UnknownCapabilities = new List(); + + foreach (string capabilityName in this.Capabilities) + { + if (Enum.TryParse(capabilityName, out var parsedCapability)) + parsed.Capabilities.Add(parsedCapability); + else + parsed.UnknownCapabilities.Add(capabilityName); + } + + return parsed; + } + } +} diff --git a/src/Client/Client/Internal/AuthorizeAccountResponseRaw.cs b/src/Client/Client/Internal/AuthorizeAccountResponseRaw.cs new file mode 100644 index 0000000..550909f --- /dev/null +++ b/src/Client/Client/Internal/AuthorizeAccountResponseRaw.cs @@ -0,0 +1,94 @@ +using System; +using System.Diagnostics; + +using Newtonsoft.Json; + +using Bytewizer.Backblaze.Models; + +namespace Bytewizer.Backblaze.Client.Internal +{ + /// + /// Contains the results of authorize account request operation. + /// + [DebuggerDisplay("{DebuggerDisplay, nq}")] + internal class AuthorizeAccountResponseRaw : IResponse + { + /// + /// The identifier for the account. + /// + [JsonProperty(Required = Required.Always)] + public string AccountId { get; internal set; } + + /// + /// An authorization token to use with all calls other than authorize account that need an authorization header. + /// This authorization token is valid for at most 24 hours. + /// + [JsonProperty(Required = Required.Always)] + public string AuthorizationToken { get; internal set; } + + /// + /// The capabilities of this auth token and any restrictions on using it. + /// + [JsonProperty(Required = Required.Always)] + public AllowedRaw Allowed { get; internal set; } + + /// + /// The base url to use for all API calls except for uploading and downloading files. + /// + [JsonProperty(Required = Required.Always)] + public Uri ApiUrl { get; internal set; } + + /// + /// The base url to use for downloading files. + /// + [JsonProperty(Required = Required.Always)] + public Uri DownloadUrl { get; internal set; } + + /// + /// The recommended size for each part of a large file. We recommend using this part size for optimal upload performance. + /// + [JsonProperty(Required = Required.Always)] + public long RecommendedPartSize { get; internal set; } + + /// + /// The smallest possible size of a part of a large file (except the last one). This is smaller than the . + /// If you use it you may find that it takes longer overall to upload a large file. + /// + [JsonProperty(Required = Required.Always)] + public long AbsoluteMinimumPartSize { get; internal set; } + + /// + /// OBSOLETE: This field will always have the same value as . + /// + [Obsolete("This field will always have the same value as 'RecommendedPartSize'.")] + public long MinimumPartSize { get; internal set; } + + /// + /// Debugger display for this object. + /// + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private string DebuggerDisplay + { + get { return $"{{{nameof(AccountId)}: {AccountId}, {nameof(AuthorizationToken)}: {AuthorizationToken}}}"; } + } + + internal AuthorizeAccountResponse ToAuthorizedAccountResponse() + { + var response = new AuthorizeAccountResponse(); + + response.AccountId = this.AccountId; + response.AuthorizationToken = this.AuthorizationToken; + response.Allowed = this.Allowed.ParseCapabilities(); + response.ApiUrl = this.ApiUrl; + response.DownloadUrl = this.DownloadUrl; + response.RecommendedPartSize = this.RecommendedPartSize; + response.AbsoluteMinimumPartSize = this.AbsoluteMinimumPartSize; + + #pragma warning disable 618 + response.MinimumPartSize = this.MinimumPartSize; + #pragma warning restore 618 + + return response; + } + } +} diff --git a/src/Client/Models/Allowed.cs b/src/Client/Models/Allowed.cs index 3caa1c5..f077983 100644 --- a/src/Client/Models/Allowed.cs +++ b/src/Client/Models/Allowed.cs @@ -1,4 +1,5 @@ -using System.Diagnostics; +using System.Collections.Generic; +using System.Diagnostics; using Newtonsoft.Json; @@ -16,6 +17,11 @@ public class Allowed [JsonProperty(Required = Required.Always)] public Capabilities Capabilities { get; set; } + /// + /// Gets or sets a list of capabilities that couldn't be parsed into values. + /// + public List UnknownCapabilities { get; set; } + /// /// Gets or sets restricted access only to this bucket id. /// @@ -39,7 +45,17 @@ public class Allowed [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay { - get { return $"{{{nameof(Capabilities)}: {string.Join(", ", Capabilities)}, {nameof(NamePrefix)}: {NamePrefix}}}"; } + get + { + string unknownCapabilitiesString = ""; + + if ((this.UnknownCapabilities != null) && (this.UnknownCapabilities.Count > 0)) + { + unknownCapabilitiesString = " (+ " + string.Join(", ", UnknownCapabilities) + ")"; + } + + return $"{{{nameof(Capabilities)}: {string.Join(", ", Capabilities)}{unknownCapabilitiesString}, {nameof(NamePrefix)}: {NamePrefix}}}"; + } } } } diff --git a/src/Client/Properties/AssemblyInfo.cs b/src/Client/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..ee0cd3a --- /dev/null +++ b/src/Client/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Backblaze.Tests.Unit")] diff --git a/test/Unit/ApiClientFixture.cs b/test/Unit/ApiClientFixture.cs new file mode 100644 index 0000000..a8e60db --- /dev/null +++ b/test/Unit/ApiClientFixture.cs @@ -0,0 +1,160 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +using Bytewizer.Backblaze.Client; +using Bytewizer.Backblaze.Client.Internal; + +using Xunit; + +using Bogus; + +using NSubstitute; + +using FluentAssertions; + +using Bytewizer.Backblaze.Models; + +namespace Backblaze.Tests.Unit +{ + public class ApiClientFixture + { + [Fact] + public async Task AuthorizeAccountAync_should_parse_capabilities() + { + // Arrange + var faker = new Faker(); + + var dummyKeyId = faker.Random.Hash(); + var dummyApplicationKey = faker.Random.Hash(); + var dummyAccountId = faker.Random.Hash(); + var dummyAuthorizationToken = faker.Random.Hash(); + var dummyApiUrl = new Uri(faker.Internet.Url()); + var dummyDownloadUrl = new Uri(faker.Internet.Url()); + var dummyCapabilities = faker.PickRandom(Enum.GetValues(typeof(Capability)).OfType(), 5).ToList(); + var dummyCapabilityStrings = dummyCapabilities.Select(c => c.ToString()).ToList(); + + var mockHttpClient = new HttpClient(); + + var clientOptions = new ClientOptions(); + + clientOptions.RequestMaxParallel = 1; + clientOptions.DownloadMaxParallel = 1; + clientOptions.UploadMaxParallel = 1; + clientOptions.TestMode = ""; + + var mockLogger = Substitute.For>(); + + var mockMemoryCache = Substitute.For(); + + using (var testServer = new TestServer()) + { + testServer + .When("/b2_authorize_account") + .Then( + new AuthorizeAccountResponseRaw() + { + AccountId = dummyAccountId, + AuthorizationToken = dummyAuthorizationToken, + ApiUrl = dummyApiUrl, + DownloadUrl = dummyDownloadUrl, + Allowed = + new AllowedRaw() + { + Capabilities = dummyCapabilityStrings + } + }); + + var sut = new ApiClient(mockHttpClient, clientOptions, mockLogger, mockMemoryCache); + + sut.AccountInfo.AuthUrl = testServer.BaseUrl; + + // Act + var result = await sut.AuthorizeAccountAync(dummyKeyId, dummyApplicationKey, CancellationToken.None); + + // Assert + result.IsSuccessStatusCode.Should().BeTrue(); + result.Response.Should().NotBeNull(); + result.Response.Allowed.Should().NotBeNull(); + result.Response.Allowed.Capabilities.Should().BeEquivalentTo(dummyCapabilities); + result.Response.Allowed.UnknownCapabilities.Should().BeEmpty(); + } + } + + [Fact] + public async Task AuthorizeAccountAync_should_parse_unknown_capabilities() + { + // Arrange + var faker = new Faker(); + + var dummyKeyId = faker.Random.Hash(); + var dummyApplicationKey = faker.Random.Hash(); + var dummyAccountId = faker.Random.Hash(); + var dummyAuthorizationToken = faker.Random.Hash(); + var dummyApiUrl = new Uri(faker.Internet.Url()); + var dummyDownloadUrl = new Uri(faker.Internet.Url()); + var dummyCapabilities = faker.PickRandom(Enum.GetValues(typeof(Capability)).OfType(), 5).ToList(); + var dummyCapabilityStrings = dummyCapabilities.Select(c => c.ToString()).ToList(); + + var unknownDummyCapabilities = + new[] + { + "UnknownCapability1", + "UnknownCapability2", + }; + + dummyCapabilityStrings.AddRange(unknownDummyCapabilities); + + var mockHttpClient = new HttpClient(); + + var clientOptions = new ClientOptions(); + + clientOptions.RequestMaxParallel = 1; + clientOptions.DownloadMaxParallel = 1; + clientOptions.UploadMaxParallel = 1; + clientOptions.TestMode = ""; + + var mockLogger = Substitute.For>(); + + var mockMemoryCache = Substitute.For(); + + using (var testServer = new TestServer()) + { + testServer + .When("/b2_authorize_account") + .Then( + new AuthorizeAccountResponseRaw() + { + AccountId = dummyAccountId, + AuthorizationToken = dummyAuthorizationToken, + ApiUrl = dummyApiUrl, + DownloadUrl = dummyDownloadUrl, + Allowed = + new AllowedRaw() + { + Capabilities = dummyCapabilityStrings + } + }); + + var sut = new ApiClient(mockHttpClient, clientOptions, mockLogger, mockMemoryCache); + + sut.AccountInfo.AuthUrl = testServer.BaseUrl; + + // Act + var result = await sut.AuthorizeAccountAync(dummyKeyId, dummyApplicationKey, CancellationToken.None); + + // Assert + result.IsSuccessStatusCode.Should().BeTrue(); + result.Response.Should().NotBeNull(); + result.Response.Allowed.Should().NotBeNull(); + result.Response.Allowed.Capabilities.Should().BeEquivalentTo(dummyCapabilities); + result.Response.Allowed.UnknownCapabilities.Should().BeEquivalentTo(unknownDummyCapabilities); + } + } + } +} \ No newline at end of file diff --git a/test/Unit/Backblaze.Tests.Unit.csproj b/test/Unit/Backblaze.Tests.Unit.csproj index 321dd10..d044dc4 100644 --- a/test/Unit/Backblaze.Tests.Unit.csproj +++ b/test/Unit/Backblaze.Tests.Unit.csproj @@ -25,7 +25,14 @@ + + + + + + + all diff --git a/test/Unit/TestServer.cs b/test/Unit/TestServer.cs new file mode 100644 index 0000000..4168b94 --- /dev/null +++ b/test/Unit/TestServer.cs @@ -0,0 +1,80 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +using Microsoft.Extensions.Hosting; + +namespace Backblaze.Tests.Unit +{ + class TestServer : IDisposable + { + WebApplication _application; + + public WebApplication Application => _application; + + public Uri BaseUrl => new Uri(_application.Urls.First()); + + public TestServer() + { + var builder = WebApplication.CreateBuilder(); + + _application = builder.Build(); + + // Without this route on the root, routes on subpaths seem to always return 404. + _application.MapGet("/", () => "Test server"); + + var lifetime = (IHostApplicationLifetime)_application.Services.GetService(typeof(IHostApplicationLifetime)); + + var started = new ManualResetEvent(initialState: false); + + lifetime.ApplicationStarted.Register(() => started.Set()); + + Task.Run(() => _application.Run()); + + started.WaitOne(); + } + + public RouteBuilder When(string route) => When(HttpMethod.Get, route); + public RouteBuilder When(HttpMethod method, string route) => new RouteBuilder(this, method, route); + + public class RouteBuilder + { + TestServer _server; + HttpMethod _method; + string _route; + + public RouteBuilder(TestServer server, HttpMethod method, string route) + { + _server = server; + _method = method; + _route = route; + } + + public void Then(object result) + => Then(() => Results.Json(result)); + + public void Then(Delegate action) + { + if (_method == HttpMethod.Get) + _server.Application.MapGet(_route, action); + else if (_method == HttpMethod.Post) + _server.Application.MapPost(_route, action); + else + throw new NotSupportedException("Not supported in TestServer: HTTP method " + _method); + } + } + + public void Dispose() + { + var task = _application.StopAsync(); + + task.ConfigureAwait(false); + task.Wait(); + } + } +} \ No newline at end of file