From a9b7f07b6c635061f9badde903bae3e25fdb49af Mon Sep 17 00:00:00 2001 From: Matthew Parker Date: Sat, 10 Aug 2024 23:47:59 +0100 Subject: [PATCH 1/4] Implemented create password recovery confirmation --- .../Clients/AccountClient.cs | 17 +++++++++++ .../Clients/IAccountClient.cs | 11 +++++++- .../Internals/IAccountApi.cs | 3 ++ src/PinguApps.Appwrite.Playground/App.cs | 7 +++-- ...eatePasswordRecoveryConfirmationRequest.cs | 28 +++++++++++++++++++ ...ordRecoveryConfirmationRequestValidator.cs | 12 ++++++++ 6 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 src/PinguApps.Appwrite.Shared/Requests/CreatePasswordRecoveryConfirmationRequest.cs create mode 100644 src/PinguApps.Appwrite.Shared/Requests/Validators/CreatePasswordRecoveryConfirmationRequestValidator.cs diff --git a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs index 9030bdec..001ed396 100644 --- a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs @@ -482,4 +482,21 @@ public async Task> CreatePasswordRecovery(CreatePasswordRe return e.GetExceptionResponse(); } } + + /// + public async Task> CreatePasswordRecoveryConfirmation(CreatePasswordRecoveryConfirmationRequest request) + { + try + { + request.Validate(true); + + var result = await _accountApi.CreatePasswordRecoveryConfirmation(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 9fddbbd1..eb444146 100644 --- a/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs @@ -220,10 +220,19 @@ public interface IAccountClient 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 + /// 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 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); + + /// + /// Use this endpoint to complete the user account password reset. Both the userId and secret arguments will be passed as query parameters to the redirect URL you have provided when sending your request to + /// Please note that in order to avoid a Redirect Attack the only valid redirect URLs are the ones from domains you have set when adding your platforms in the console interface + /// Appwrite Docs + /// + /// + /// + Task> CreatePasswordRecoveryConfirmation(CreatePasswordRecoveryConfirmationRequest request); } diff --git a/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs b/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs index d5c43d7e..c1358f69 100644 --- a/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs +++ b/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs @@ -89,4 +89,7 @@ internal interface IAccountApi : IBaseApi [Post("/account/recovery")] Task> CreatePasswordRecovery(CreatePasswordRecoveryRequest request); + + [Put("/account/recovery")] + Task> CreatePasswordRecoveryConfirmation(CreatePasswordRecoveryConfirmationRequest request); } diff --git a/src/PinguApps.Appwrite.Playground/App.cs b/src/PinguApps.Appwrite.Playground/App.cs index 43a23413..5a5b4644 100644 --- a/src/PinguApps.Appwrite.Playground/App.cs +++ b/src/PinguApps.Appwrite.Playground/App.cs @@ -20,10 +20,11 @@ public async Task Run(string[] args) { _client.SetSession(_session); - var response = await _client.Account.CreatePasswordRecovery(new Shared.Requests.CreatePasswordRecoveryRequest + var response = await _client.Account.CreatePasswordRecoveryConfirmation(new Shared.Requests.CreatePasswordRecoveryConfirmationRequest { - Email = "pingu@example.com", - Url = "https://localhost:5001/abc" + UserId = "censored", + Secret = "censored", + Password = "MyNewSuperAwesomePassword!" }); Console.WriteLine(response.Result.Match( diff --git a/src/PinguApps.Appwrite.Shared/Requests/CreatePasswordRecoveryConfirmationRequest.cs b/src/PinguApps.Appwrite.Shared/Requests/CreatePasswordRecoveryConfirmationRequest.cs new file mode 100644 index 00000000..73ec2f64 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Requests/CreatePasswordRecoveryConfirmationRequest.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; +using PinguApps.Appwrite.Shared.Requests.Validators; + +namespace PinguApps.Appwrite.Shared.Requests; + +/// +/// The request for creating a password recovery confirmation +/// +public class CreatePasswordRecoveryConfirmationRequest : BaseRequest +{ + /// + /// User ID + /// + [JsonPropertyName("userId")] + public string UserId { get; set; } = string.Empty; + + /// + /// Valid reset token + /// + [JsonPropertyName("secret")] + public string Secret { get; set; } = string.Empty; + + /// + /// New user password. Must be between 8 and 256 chars + /// + [JsonPropertyName("password")] + public string Password { get; set; } = string.Empty; +} diff --git a/src/PinguApps.Appwrite.Shared/Requests/Validators/CreatePasswordRecoveryConfirmationRequestValidator.cs b/src/PinguApps.Appwrite.Shared/Requests/Validators/CreatePasswordRecoveryConfirmationRequestValidator.cs new file mode 100644 index 00000000..cfd4b663 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Requests/Validators/CreatePasswordRecoveryConfirmationRequestValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace PinguApps.Appwrite.Shared.Requests.Validators; +public class CreatePasswordRecoveryConfirmationRequestValidator : AbstractValidator +{ + public CreatePasswordRecoveryConfirmationRequestValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.Secret).NotEmpty(); + RuleFor(x => x.Password).NotEmpty().MinimumLength(8).MaximumLength(256); + } +} From 01c13b048830bfbbb05265b2aef9442dac1d95c6 Mon Sep 17 00:00:00 2001 From: Matthew Parker Date: Sat, 10 Aug 2024 23:55:14 +0100 Subject: [PATCH 2/4] Added shared tests --- ...asswordRecoveryConfirmationRequestTests.cs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/PinguApps.Appwrite.Shared.Tests/Requests/CreatePasswordRecoveryConfirmationRequestTests.cs diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreatePasswordRecoveryConfirmationRequestTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreatePasswordRecoveryConfirmationRequestTests.cs new file mode 100644 index 00000000..3ba3a869 --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreatePasswordRecoveryConfirmationRequestTests.cs @@ -0,0 +1,116 @@ +using FluentValidation; +using PinguApps.Appwrite.Shared.Requests; + +namespace PinguApps.Appwrite.Shared.Tests.Requests; +public class CreatePasswordRecoveryConfirmationRequestTests +{ + [Fact] + public void Constructor_InitializesWithExpectedValues() + { + // Arrange & Act + var request = new CreatePasswordRecoveryConfirmationRequest(); + + // Assert + Assert.Equal(string.Empty, request.UserId); + Assert.Equal(string.Empty, request.Secret); + Assert.Equal(string.Empty, request.Password); + } + + [Fact] + public void Properties_CanBeSet() + { + var userId = "userId"; + var secret = "secret"; + var password = "password"; + + // Arrange + var request = new CreatePasswordRecoveryConfirmationRequest(); + + // Act + request.UserId = userId; + request.Secret = secret; + request.Password = password; + + // Assert + Assert.Equal(userId, request.UserId); + Assert.Equal(secret, request.Secret); + Assert.Equal(password, request.Password); + } + + [Fact] + public void IsValid_WithValidData_ReturnsTrue() + { + // Arrange + var request = new CreatePasswordRecoveryConfirmationRequest + { + UserId = "userId", + Secret = "secret", + Password = "password" + }; + + // Act + var isValid = request.IsValid(); + + // Assert + Assert.True(isValid); + } + + [Theory] + [InlineData(null, "secret", "password")] + [InlineData("", "secret", "password")] + [InlineData("userId", null, "password")] + [InlineData("userId", "", "password")] + [InlineData("userId", "secret", null)] + [InlineData("userId", "secret", "")] + [InlineData("userId", "secret", "short")] + [InlineData("userId", "secret", "A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. \", \"A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. A much longer string. ")] + public void IsValid_WithInvalidData_ReturnsFalse(string? userId, string? secret, string? password) + { + // Arrange + var request = new CreatePasswordRecoveryConfirmationRequest + { + UserId = userId!, + Secret = secret!, + Password = password! + }; + + // Act + var isValid = request.IsValid(); + + // Assert + Assert.False(isValid); + } + + [Fact] + public void Validate_WithThrowOnFailuresTrue_ThrowsValidationExceptionOnFailure() + { + // Arrange + var request = new CreatePasswordRecoveryConfirmationRequest + { + UserId = "", + Secret = "", + Password = "short" + }; + + // Assert + Assert.Throws(() => request.Validate(true)); + } + + [Fact] + public void Validate_WithThrowOnFailuresFalse_ReturnsInvalidResultOnFailure() + { + // Arrange + var request = new CreatePasswordRecoveryConfirmationRequest + { + UserId = "", + Secret = "", + Password = "short" + }; + + // Act + var result = request.Validate(false); + + // Assert + Assert.False(result.IsValid); + } +} From ac3c21929b777f94c4c84912103070229e6f8653 Mon Sep 17 00:00:00 2001 From: Matthew Parker Date: Sat, 10 Aug 2024 23:57:52 +0100 Subject: [PATCH 3/4] added client tests --- ...ests.CreatePasswordRecoveryConfirmation.cs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreatePasswordRecoveryConfirmation.cs diff --git a/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreatePasswordRecoveryConfirmation.cs b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreatePasswordRecoveryConfirmation.cs new file mode 100644 index 00000000..7004e245 --- /dev/null +++ b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreatePasswordRecoveryConfirmation.cs @@ -0,0 +1,80 @@ +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 CreatePasswordRecoveryConfirmation_ShouldReturnSuccess_WhenApiCallSucceeds() + { + // Arrange + var request = new CreatePasswordRecoveryConfirmationRequest() + { + UserId = "userId", + Secret = "secret", + Password = "password" + }; + + _mockHttp.Expect(HttpMethod.Put, $"{Constants.Endpoint}/account/recovery") + .ExpectedHeaders() + .WithJsonContent(request) + .Respond(Constants.AppJson, Constants.TokenResponse); + + // Act + var result = await _appwriteClient.Account.CreatePasswordRecoveryConfirmation(request); + + // Assert + Assert.True(result.Success); + } + + [Fact] + public async Task CreatePasswordRecoveryConfirmation_ShouldHandleException_WhenApiCallFails() + { + // Arrange + var request = new CreatePasswordRecoveryConfirmationRequest() + { + UserId = "userId", + Secret = "secret", + Password = "password" + }; + + _mockHttp.Expect(HttpMethod.Put, $"{Constants.Endpoint}/account/recovery") + .ExpectedHeaders() + .WithJsonContent(request) + .Respond(HttpStatusCode.BadRequest, Constants.AppJson, Constants.AppwriteError); + + // Act + var result = await _appwriteClient.Account.CreatePasswordRecoveryConfirmation(request); + + // Assert + Assert.True(result.IsError); + Assert.True(result.IsAppwriteError); + } + + [Fact] + public async Task CreatePasswordRecoveryConfirmation_ShouldReturnErrorResponse_WhenExceptionOccurs() + { + // Arrange + var request = new CreatePasswordRecoveryConfirmationRequest() + { + UserId = "userId", + Secret = "secret", + Password = "password" + }; + + _mockHttp.Expect(HttpMethod.Put, $"{Constants.Endpoint}/account/recovery") + .ExpectedHeaders() + .WithJsonContent(request) + .Throw(new HttpRequestException("An error occurred")); + + // Act + var result = await _appwriteClient.Account.CreatePasswordRecoveryConfirmation(request); + + // Assert + Assert.False(result.Success); + Assert.True(result.IsInternalError); + Assert.Equal("An error occurred", result.Result.AsT2.Message); + } +} From f1ef5b4505f1059ca8a6e3607d5f1caddecd8ce5 Mon Sep 17 00:00:00 2001 From: Matthew Parker Date: Sat, 10 Aug 2024 23:59:10 +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 5cb7ad60..4907572d 100644 --- a/README.md +++ b/README.md @@ -138,14 +138,14 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( ``` ## ⌛ Progress - -![Server & Client - 29 / 288](https://img.shields.io/badge/Server_&_Client-29%20%2F%20288-red?style=for-the-badge) + +![Server & Client - 30 / 288](https://img.shields.io/badge/Server_&_Client-30%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 - 27 / 93](https://img.shields.io/badge/Client-27%20%2F%2093-red?style=for-the-badge) + +![Client - 28 / 93](https://img.shields.io/badge/Client-28%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 - 29 / 52](https://img.shields.io/badge/Account-29%20%2F%2052-yellow?style=for-the-badge) + +![Account - 30 / 52](https://img.shields.io/badge/Account-30%20%2F%2052-yellow?style=for-the-badge) | Endpoint | Client | Server | |:-:|:-:|:-:| @@ -183,7 +183,7 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [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 (Confirmation)](https://appwrite.io/docs/references/1.5.x/client-rest/account#updateRecovery) | ⬛ | ❌ | +| [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) | ⬛ | ❌ | | [Create Anonymous Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#createAnonymousSession) | ⬛ | ❌ |