From 888faa13818ad0aed60b237f109e0d80e2346a9b Mon Sep 17 00:00:00 2001 From: Matthew Parker Date: Sat, 10 Aug 2024 23:26:06 +0100 Subject: [PATCH 1/4] Implemented CreatePasswordRecovery --- .../Clients/AccountClient.cs | 17 ++++++++++++++ .../Clients/IAccountClient.cs | 10 ++++++++- .../Internals/IAccountApi.cs | 3 +++ src/PinguApps.Appwrite.Playground/App.cs | 6 ++++- .../Requests/CreatePasswordRecoveryRequest.cs | 22 +++++++++++++++++++ .../CreatePasswordRecoveryRequestValidator.cs | 12 ++++++++++ 6 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 src/PinguApps.Appwrite.Shared/Requests/CreatePasswordRecoveryRequest.cs create mode 100644 src/PinguApps.Appwrite.Shared/Requests/Validators/CreatePasswordRecoveryRequestValidator.cs diff --git a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs index d306e2ee..9030bdec 100644 --- a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs @@ -465,4 +465,21 @@ public async Task> RegenerateMfaRecoveryCodes() return e.GetExceptionResponse(); } } + + /// + public async Task> CreatePasswordRecovery(CreatePasswordRecoveryRequest request) + { + try + { + request.Validate(true); + + var result = await _accountApi.CreatePasswordRecovery(request); + + 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 c4925d50..9fddbbd1 100644 --- a/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs @@ -214,8 +214,16 @@ public interface IAccountClient /// /// Regenerate recovery codes that can be used as backup for MFA flow. Before regenerating codes, they must be first generated using method. An OTP challenge is required to regenreate recovery codes - /// Appwrite Docs + /// Appwrite Docs /// /// Task> RegenerateMfaRecoveryCodes(); + + /// + /// Sends the user an email with a temporary secret key for password reset. When the user clicks the confirmation link he is redirected back to your app password reset URL with the secret key and email address values attached to the URL query string. Use the query string params to submit a request to the PUT /account/recovery endpoint to complete the process. The verification link sent to the user's email address is valid for 1 hour + /// Appwrite Docs + /// + /// The request content + /// The Token + Task> CreatePasswordRecovery(CreatePasswordRecoveryRequest request); } diff --git a/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs b/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs index 1e375894..d5c43d7e 100644 --- a/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs +++ b/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs @@ -86,4 +86,7 @@ internal interface IAccountApi : IBaseApi [Patch("/account/mfa/recovery-codes")] Task> RegenerateMfaRecoveryCodes([Header("x-appwrite-session")] string session); + + [Post("/account/recovery")] + Task> CreatePasswordRecovery(CreatePasswordRecoveryRequest request); } diff --git a/src/PinguApps.Appwrite.Playground/App.cs b/src/PinguApps.Appwrite.Playground/App.cs index e43b44e8..43a23413 100644 --- a/src/PinguApps.Appwrite.Playground/App.cs +++ b/src/PinguApps.Appwrite.Playground/App.cs @@ -20,7 +20,11 @@ public async Task Run(string[] args) { _client.SetSession(_session); - var response = await _client.Account.RegenerateMfaRecoveryCodes(); + var response = await _client.Account.CreatePasswordRecovery(new Shared.Requests.CreatePasswordRecoveryRequest + { + Email = "pingu@example.com", + Url = "https://localhost:5001/abc" + }); Console.WriteLine(response.Result.Match( account => account.ToString(), diff --git a/src/PinguApps.Appwrite.Shared/Requests/CreatePasswordRecoveryRequest.cs b/src/PinguApps.Appwrite.Shared/Requests/CreatePasswordRecoveryRequest.cs new file mode 100644 index 00000000..81398b88 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Requests/CreatePasswordRecoveryRequest.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using PinguApps.Appwrite.Shared.Requests.Validators; + +namespace PinguApps.Appwrite.Shared.Requests; + +/// +/// The request for creating a password recovery +/// +public class CreatePasswordRecoveryRequest : BaseRequest +{ + /// + /// User email + /// + [JsonPropertyName("email")] + public string Email { get; set; } = string.Empty; + + /// + /// URL to redirect the user back to your app from the recovery email. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an open redirect attack against your project API + /// + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; +} diff --git a/src/PinguApps.Appwrite.Shared/Requests/Validators/CreatePasswordRecoveryRequestValidator.cs b/src/PinguApps.Appwrite.Shared/Requests/Validators/CreatePasswordRecoveryRequestValidator.cs new file mode 100644 index 00000000..7535b225 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Requests/Validators/CreatePasswordRecoveryRequestValidator.cs @@ -0,0 +1,12 @@ +using System; +using FluentValidation; + +namespace PinguApps.Appwrite.Shared.Requests.Validators; +public class CreatePasswordRecoveryRequestValidator : AbstractValidator +{ + public CreatePasswordRecoveryRequestValidator() + { + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + RuleFor(x => x.Url).NotEmpty().Must(uri => Uri.TryCreate(uri, UriKind.Absolute, out _)); + } +} From f0b0ea19b9d61270bfda4e89aa103344a6595d20 Mon Sep 17 00:00:00 2001 From: Matthew Parker Date: Sat, 10 Aug 2024 23:28:09 +0100 Subject: [PATCH 2/4] added client tests --- ...countClientTests.CreatePasswordRecovery.cs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreatePasswordRecovery.cs diff --git a/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreatePasswordRecovery.cs b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreatePasswordRecovery.cs new file mode 100644 index 00000000..5f214cb4 --- /dev/null +++ b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreatePasswordRecovery.cs @@ -0,0 +1,77 @@ +using System.Net; +using PinguApps.Appwrite.Shared.Requests; +using PinguApps.Appwrite.Shared.Tests; +using RichardSzalay.MockHttp; + +namespace PinguApps.Appwrite.Client.Tests.Clients.Account; +public partial class AccountClientTests +{ + [Fact] + public async Task CreatePasswordRecovery_ShouldReturnSuccess_WhenApiCallSucceeds() + { + // Arrange + var request = new CreatePasswordRecoveryRequest() + { + Email = "pingu@example.com", + Url = "https://localhost:1234/abc" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/recovery") + .ExpectedHeaders() + .WithJsonContent(request) + .Respond(Constants.AppJson, Constants.TokenResponse); + + // Act + var result = await _appwriteClient.Account.CreatePasswordRecovery(request); + + // Assert + Assert.True(result.Success); + } + + [Fact] + public async Task CreatePasswordRecovery_ShouldHandleException_WhenApiCallFails() + { + // Arrange + var request = new CreatePasswordRecoveryRequest() + { + Email = "pingu@example.com", + Url = "https://localhost:1234/abc" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/recovery") + .ExpectedHeaders() + .WithJsonContent(request) + .Respond(HttpStatusCode.BadRequest, Constants.AppJson, Constants.AppwriteError); + + // Act + var result = await _appwriteClient.Account.CreatePasswordRecovery(request); + + // Assert + Assert.True(result.IsError); + Assert.True(result.IsAppwriteError); + } + + [Fact] + public async Task CreatePasswordRecovery_ShouldReturnErrorResponse_WhenExceptionOccurs() + { + // Arrange + var request = new CreatePasswordRecoveryRequest() + { + Email = "pingu@example.com", + Url = "https://localhost:1234/abc" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/recovery") + .ExpectedHeaders() + .WithJsonContent(request) + .Throw(new HttpRequestException("An error occurred")); + + // Act + var result = await _appwriteClient.Account.CreatePasswordRecovery(request); + + // Assert + Assert.False(result.Success); + Assert.True(result.IsInternalError); + Assert.Equal("An error occurred", result.Result.AsT2.Message); + } +} From 8ec32ef105d583e20349f1cb79c0b9014ba4f74d Mon Sep 17 00:00:00 2001 From: Matthew Parker Date: Sat, 10 Aug 2024 23:34:08 +0100 Subject: [PATCH 3/4] added shared tests --- .../Requests/CreatePasswordRecoveryTests.cs | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 tests/PinguApps.Appwrite.Shared.Tests/Requests/CreatePasswordRecoveryTests.cs diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreatePasswordRecoveryTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreatePasswordRecoveryTests.cs new file mode 100644 index 00000000..177d4022 --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreatePasswordRecoveryTests.cs @@ -0,0 +1,106 @@ +using FluentValidation; +using PinguApps.Appwrite.Shared.Requests; + +namespace PinguApps.Appwrite.Shared.Tests.Requests; +public class CreatePasswordRecoveryTests +{ + [Fact] + public void Constructor_InitializesWithExpectedValues() + { + // Arrange & Act + var request = new CreatePasswordRecoveryRequest(); + + // Assert + Assert.Equal(string.Empty, request.Email); + Assert.Equal(string.Empty, request.Url); + } + + [Fact] + public void Properties_CanBeSet() + { + var url = "https://localhost:1234/abc"; + var email = "test@example.com"; + + // Arrange + var request = new CreatePasswordRecoveryRequest(); + + // Act + request.Url = url; + request.Email = email; + + // Assert + Assert.Equal(url, request.Url); + Assert.Equal(email, request.Email); + } + + [Fact] + public void IsValid_WithValidData_ReturnsTrue() + { + // Arrange + var request = new CreatePasswordRecoveryRequest + { + Email = "test@example.com", + Url = "https://localhost:1234/abc" + }; + + // Act + var isValid = request.IsValid(); + + // Assert + Assert.True(isValid); + } + + [Theory] + [InlineData(null, "https://localhost:1234/abc")] + [InlineData("", "https://localhost:1234/abc")] + [InlineData("Not an email", "https://localhost:1234/abc")] + [InlineData("pingu@example.com", null)] + [InlineData("pingu@example.com", "")] + [InlineData("pingu@example.com", "Not a URL")] + public void IsValid_WithInvalidData_ReturnsFalse(string? email, string? url) + { + // Arrange + var request = new CreatePasswordRecoveryRequest + { + Email = email!, + Url = url! + }; + + // Act + var isValid = request.IsValid(); + + // Assert + Assert.False(isValid); + } + + [Fact] + public void Validate_WithThrowOnFailuresTrue_ThrowsValidationExceptionOnFailure() + { + // Arrange + var request = new CreatePasswordRecoveryRequest + { + Email = "", + Url = "" + }; + + // Assert + Assert.Throws(() => request.Validate(true)); + } + + [Fact] + public void Validate_WithThrowOnFailuresFalse_ReturnsInvalidResultOnFailure() + { + // Arrange + var request = new CreatePasswordRecoveryRequest + { + Email = "", + Url = "" + }; + + // Act + var result = request.Validate(false); + + // Assert + Assert.False(result.IsValid); + } +} From 18b7900508cb43ea4acd67248b746bff9c1ce21f Mon Sep 17 00:00:00 2001 From: Matthew Parker Date: Sat, 10 Aug 2024 23:35:11 +0100 Subject: [PATCH 4/4] Update README.md --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6c9b3704..5cb7ad60 100644 --- a/README.md +++ b/README.md @@ -138,14 +138,14 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( ``` ## ⌛ Progress - -![Server & Client - 28 / 288](https://img.shields.io/badge/Server_&_Client-28%20%2F%20288-red?style=for-the-badge) + +![Server & Client - 29 / 288](https://img.shields.io/badge/Server_&_Client-29%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 - 26 / 93](https://img.shields.io/badge/Client-26%20%2F%2093-red?style=for-the-badge) + +![Client - 27 / 93](https://img.shields.io/badge/Client-27%20%2F%2093-red?style=for-the-badge) ### 🔑 Key | Icon | Definition | @@ -155,8 +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 - -![Account - 28 / 52](https://img.shields.io/badge/Account-28%20%2F%2052-yellow?style=for-the-badge) + +![Account - 29 / 52](https://img.shields.io/badge/Account-29%20%2F%2052-yellow?style=for-the-badge) | Endpoint | Client | Server | |:-:|:-:|:-:| @@ -182,7 +182,7 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [Update Phone](https://appwrite.io/docs/references/1.5.x/client-rest/account#updatePhone) | ✅ | ❌ | | [Get Account Preferences](https://appwrite.io/docs/references/1.5.x/client-rest/account#getPrefs) | ✅ | ❌ | | [Update Preferences](https://appwrite.io/docs/references/1.5.x/client-rest/account#updatePrefs) | ✅ | ❌ | -| [Create Password Recovery](https://appwrite.io/docs/references/1.5.x/client-rest/account#createRecovery) | ⬛ | ❌ | +| [Create Password Recovery](https://appwrite.io/docs/references/1.5.x/client-rest/account#createRecovery) | ✅ | ❌ | | [Create Password Recovery (Confirmation)](https://appwrite.io/docs/references/1.5.x/client-rest/account#updateRecovery) | ⬛ | ❌ | | [List Sessions](https://appwrite.io/docs/references/1.5.x/client-rest/account#listSessions) | ⬛ | ❌ | | [Delete Sessions](https://appwrite.io/docs/references/1.5.x/client-rest/account#deleteSessions) | ⬛ | ❌ |