diff --git a/README.md b/README.md index 96b04bed..5466021d 100644 --- a/README.md +++ b/README.md @@ -138,12 +138,14 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( ``` ## ⌛ Progress -### Server & Client -![18 / 288](https://progress-bar.dev/18/?scale=288&suffix=%20/%20288&width=500) -### Server Only -![2 / 195](https://progress-bar.dev/2/?scale=195&suffix=%20/%20195&width=300) -### Client Only -![16 / 93](https://progress-bar.dev/16/?scale=93&suffix=%20/%2093&width=300) + +![Server & Client - 19 / 288](https://img.shields.io/badge/Server_&_Client-19%20%2F%20288-red?style=for-the-badge) + + +![Server - 2 / 195](https://img.shields.io/badge/Server-2%20%2F%20195-red?style=for-the-badge) + + +![Client - 17 / 93](https://img.shields.io/badge/Client-17%20%2F%2093-red?style=for-the-badge) ### 🔑 Key | Icon | Definition | @@ -153,7 +155,8 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | ❌ | There is currently no intention to implement the endpoint for the given SDK type (client or server) | ### Account -![18 / 52](https://progress-bar.dev/18/?scale=52&suffix=%20/%2052&width=120) + +![Account - 19 / 52](https://img.shields.io/badge/Account-19%20%2F%2052-yellow?style=for-the-badge) | Endpoint | Client | Server | |:-:|:-:|:-:| @@ -165,7 +168,7 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [Create JWT](https://appwrite.io/docs/references/1.5.x/client-rest/account#createJWT) | ✅ | ❌ | | [List Logs](https://appwrite.io/docs/references/1.5.x/client-rest/account#listLogs) | ✅ | ❌ | | [Update MFA](https://appwrite.io/docs/references/1.5.x/client-rest/account#updateMFA) | ⬛ | ❌ | -| [Add Authenticator](https://appwrite.io/docs/references/1.5.x/client-rest/account#createMfaAuthenticator) | ⬛ | ❌ | +| [Add Authenticator](https://appwrite.io/docs/references/1.5.x/client-rest/account#createMfaAuthenticator) | ✅ | ❌ | | [Verify Authenticator](https://appwrite.io/docs/references/1.5.x/client-rest/account#updateMfaAuthenticator) | ⬛ | ❌ | | [Delete Authenticator](https://appwrite.io/docs/references/1.5.x/client-rest/account#deleteMfaAuthenticator) | ⬛ | ❌ | | [Create 2FA Challenge](https://appwrite.io/docs/references/1.5.x/client-rest/account#createMfaChallenge) | ⬛ | ❌ | @@ -206,7 +209,8 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [Create Phone Verification (Confirmation)](https://appwrite.io/docs/references/1.5.x/client-rest/account#updatePhoneVerification) | ⬛ | ❌ | ### Users -![0 / 41](https://progress-bar.dev/0/?scale=41&suffix=%20/%2041&width=120) + +![Account - 0 / 41](https://img.shields.io/badge/Users-0%20%2F%2041-red?style=for-the-badge) | Endpoint | Client | Server | |:-:|:-:|:-:| @@ -253,7 +257,8 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [Update Phone Verification](https://appwrite.io/docs/references/1.5.x/server-rest/users#updatePhoneVerification) | ❌ | ⬛ | ### Teams -![0 / 26](https://progress-bar.dev/0/?scale=26&suffix=%20/%2026&width=120) + +![Teams - 0 / 26](https://img.shields.io/badge/Teams-0%20%2F%2026-red?style=for-the-badge) | Endpoint | Client | Server | |:-:|:-:|:-:| @@ -272,7 +277,8 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [Update Preferences](https://appwrite.io/docs/references/1.5.x/client-rest/teams#updatePrefs) | ⬛ | ⬛ | ### Databases -![0 / 47](https://progress-bar.dev/0/?scale=47&suffix=%20/%2047&width=120) + +![Databases - 0 / 47](https://img.shields.io/badge/Databases-0%20%2F%2047-red?style=for-the-badge) | Endpoint | Client | Server | |:-:|:-:|:-:| @@ -320,7 +326,8 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [Delete Index](https://appwrite.io/docs/references/1.5.x/server-rest/databases#deleteIndex) | ❌ | ⬛ | ### Storage -![0 / 21](https://progress-bar.dev/0/?scale=21&suffix=%20/%2021&width=120) + +![storage - 0 / 21](https://img.shields.io/badge/Storage-0%20%2F%2021-red?style=for-the-badge) | Endpoint | Client | Server | |:-:|:-:|:-:| @@ -339,7 +346,8 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [Get File For View](https://appwrite.io/docs/references/1.5.x/client-rest/storage#getFileView) | ⬛ | ⬛ | ### Functions -![0 / 24](https://progress-bar.dev/0/?scale=24&suffix=%20/%2024&width=120) + +![Functions - 0 / 24](https://img.shields.io/badge/Functions-0%20%2F%2024-red?style=for-the-badge) | Endpoint | Client | Server | |:-:|:-:|:-:| @@ -366,7 +374,8 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [Delete Variable](https://appwrite.io/docs/references/1.5.x/server-rest/functions#deleteVariable) | ❌ | ⬛ | ### Messaging -![0 / 48](https://progress-bar.dev/0/?scale=48&suffix=%20/%2048&width=120) + +![Messaging - 0 / 48](https://img.shields.io/badge/Messaging-0%20%2F%2048-red?style=for-the-badge) | Endpoint | Client | Server | |:-:|:-:|:-:| @@ -418,7 +427,8 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [Delete Subscriber](https://appwrite.io/docs/references/1.5.x/client-rest/messaging#deleteSubscriber) | ⬛ | ⬛ | ### Locale -![0 / 15](https://progress-bar.dev/0/?scale=15&suffix=%20/%2015&width=120) + +![Locale - 0 / 15](https://img.shields.io/badge/Locale-0%20%2F%2015-red?style=for-the-badge) | Endpoint | Client | Server | |:-:|:-:|:-:| @@ -432,7 +442,8 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [List Languages](https://appwrite.io/docs/references/1.5.x/client-rest/locale#listLanguages) | ⬛ | ⬛ | ### Avatars -![0 / 14](https://progress-bar.dev/0/?scale=14&suffix=%20/%2014&width=120) + +![Avatars - 0 / 14](https://img.shields.io/badge/Avatars-0%20%2F%2014-red?style=for-the-badge) | Endpoint | Client | Server | |:-:|:-:|:-:| diff --git a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs index 2463a682..d170fe91 100644 --- a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs @@ -297,4 +297,19 @@ public async Task> ListLogs(List? queries = null return e.GetExceptionResponse(); } } + + /// + public async Task> AddAuthenticator(string type = "totp") + { + try + { + var result = await _accountApi.AddAuthenticator(Session, type); + + return result.GetApiResponse(); + } + catch (Exception e) + { + return e.GetExceptionResponse(); + } + } } diff --git a/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs b/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs index 7245f3b2..d5c30634 100644 --- a/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs @@ -141,4 +141,12 @@ public interface IAccountClient /// Array of query strings generated using the Query class provided by the SDK. Learn more about queries. Only supported methods are limit and offset /// The Logs List Task> ListLogs(List? queries = null); + + /// + /// Add an authenticator app to be used as an MFA factor. Verify the authenticator using the verify authenticator method + /// Appwrite Docs + /// + /// Type of authenticator. Must be `totp` + /// The MfaType + Task> AddAuthenticator(string type = "totp"); } diff --git a/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs b/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs index d382268e..8290cd9a 100644 --- a/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs +++ b/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs @@ -56,4 +56,7 @@ internal interface IAccountApi : IBaseApi [Get("/account/logs")] [QueryUriFormat(System.UriFormat.Unescaped)] Task> ListLogs([Header("x-appwrite-session")] string? session, [Query(CollectionFormat.Multi), AliasAs("queries[]")] IEnumerable queries); + + [Post("/account/mfa/authenticators/{type}")] + Task> AddAuthenticator([Header("x-appwrite-session")] string? session, string type); } diff --git a/src/PinguApps.Appwrite.Playground/App.cs b/src/PinguApps.Appwrite.Playground/App.cs index 391ca95c..569ae22d 100644 --- a/src/PinguApps.Appwrite.Playground/App.cs +++ b/src/PinguApps.Appwrite.Playground/App.cs @@ -1,7 +1,6 @@ using Microsoft.Extensions.Configuration; using PinguApps.Appwrite.Client; using PinguApps.Appwrite.Server.Servers; -using PinguApps.Appwrite.Shared.Utils; namespace PinguApps.Appwrite.Playground; internal class App @@ -21,7 +20,7 @@ public async Task Run(string[] args) { _client.SetSession(_session); - var response = await _client.Account.ListLogs([Query.Limit(2)]); + var response = await _client.Account.AddAuthenticator(); Console.WriteLine(response.Result.Match( account => account.ToString(), diff --git a/src/PinguApps.Appwrite.Shared/Responses/MfaType.cs b/src/PinguApps.Appwrite.Shared/Responses/MfaType.cs new file mode 100644 index 00000000..7abcd0bb --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Responses/MfaType.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace PinguApps.Appwrite.Shared.Responses; + +/// +/// An Appwrite Mfa Type object +/// +/// Secret token used for TOTP factor +/// URI for authenticator apps +public record MfaType( + [property: JsonPropertyName("secret")] string Secret, + [property: JsonPropertyName("uri")] string Uri +); diff --git a/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.AddAuthenticator.cs b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.AddAuthenticator.cs new file mode 100644 index 00000000..47b12a33 --- /dev/null +++ b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.AddAuthenticator.cs @@ -0,0 +1,82 @@ +using System.Net; +using PinguApps.Appwrite.Shared.Tests; +using RichardSzalay.MockHttp; + +namespace PinguApps.Appwrite.Client.Tests.Clients.Account; +public partial class AccountClientTests +{ + [Fact] + public async Task AddAuthenticator_ShouldReturnSuccess_WhenApiCallSucceeds() + { + // Arrange + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/mfa/authenticators/totp") + .ExpectedHeaders(true) + .Respond(Constants.AppJson, Constants.MfaTypeResponse); + + _appwriteClient.SetSession(Constants.Session); + + // Act + var result = await _appwriteClient.Account.AddAuthenticator(); + + // Assert + Assert.True(result.Success); + } + + [Fact] + public async Task AddAuthenticator_ShouldHitDifferentEndpoint_WhenNewTypeIsUsed() + { + // Arrange + var type = "newAuth"; + var requestUri = $"{Constants.Endpoint}/account/mfa/authenticators/{type}"; + var request = _mockHttp.Expect(HttpMethod.Post, requestUri) + .ExpectedHeaders(true) + .Respond(Constants.AppJson, Constants.MfaTypeResponse); + + _appwriteClient.SetSession(Constants.Session); + + // Act + var result = await _appwriteClient.Account.AddAuthenticator(type); + + // Assert + _mockHttp.VerifyNoOutstandingExpectation(); + var matches = _mockHttp.GetMatchCount(request); + Assert.Equal(1, matches); + } + + [Fact] + public async Task AddAuthenticator_ShouldHandleException_WhenApiCallFails() + { + // Arrange + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/mfa/authenticators/totp") + .ExpectedHeaders(true) + .Respond(HttpStatusCode.BadRequest, Constants.AppJson, Constants.AppwriteError); + + _appwriteClient.SetSession(Constants.Session); + + // Act + var result = await _appwriteClient.Account.AddAuthenticator(); + + // Assert + Assert.True(result.IsError); + Assert.True(result.IsAppwriteError); + } + + [Fact] + public async Task AddAuthenticator_ShouldReturnErrorResponse_WhenExceptionOccurs() + { + // Arrange + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/mfa/authenticators/totp") + .ExpectedHeaders(true) + .Throw(new HttpRequestException("An error occurred")); + + _appwriteClient.SetSession(Constants.Session); + + // Act + var result = await _appwriteClient.Account.AddAuthenticator(); + + // Assert + Assert.False(result.Success); + Assert.True(result.IsInternalError); + Assert.Equal("An error occurred", result.Result.AsT2.Message); + } +} diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Constants.cs b/tests/PinguApps.Appwrite.Shared.Tests/Constants.cs index 1f9f31b7..a77d8c55 100644 --- a/tests/PinguApps.Appwrite.Shared.Tests/Constants.cs +++ b/tests/PinguApps.Appwrite.Shared.Tests/Constants.cs @@ -163,4 +163,11 @@ public static class Constants ] } """; + + public const string MfaTypeResponse = """ + { + "secret": "The_Secret", + "uri": "otpauth://totp/Appwrite%20Test%3Apingu%40appwrite.com?issuer=Appwrite%20Test&secret=The_Secret" + } + """; } diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Responses/MfaTypeTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/Responses/MfaTypeTests.cs new file mode 100644 index 00000000..6e493c1d --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/Responses/MfaTypeTests.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using PinguApps.Appwrite.Shared.Responses; + +namespace PinguApps.Appwrite.Shared.Tests.Responses; +public class MfaTypeTests +{ + [Fact] + public void LogsList_Constructor_AssignsPropertiesCorrectly() + { + // Arrange + var secret = "The_Secret"; + var uri = "otpauth://totp/Appwrite%20Test%3Apingu%40appwrite.com?issuer=Appwrite%20Test&secret=The_Secret"; + + // Act + var mfaType = new MfaType(secret, uri); + + // Assert + Assert.Equal(secret, mfaType.Secret); + Assert.Equal(uri, mfaType.Uri); + } + + [Fact] + public void LogsList_CanBeDeserialized_FromJson() + { + // Act + var mfaType = JsonSerializer.Deserialize(Constants.MfaTypeResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + // Assert + Assert.NotNull(mfaType); + Assert.Equal("The_Secret", mfaType.Secret); + Assert.Equal("otpauth://totp/Appwrite%20Test%3Apingu%40appwrite.com?issuer=Appwrite%20Test&secret=The_Secret", mfaType.Uri); + } +}