Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented create password recovery confirmation #130

Merged
merged 4 commits into from
Aug 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,14 @@ string emailAddressOrErrorMessage = userResponse.Result.Match(
```

## ⌛ Progress
<!-- ![29 / 288](https://progress-bar.dev/29/?scale=288&suffix=%20/%20288&width=500) -->
![Server & Client - 29 / 288](https://img.shields.io/badge/Server_&_Client-29%20%2F%20288-red?style=for-the-badge)
<!-- ![30 / 288](https://progress-bar.dev/30/?scale=288&suffix=%20/%20288&width=500) -->
![Server & Client - 30 / 288](https://img.shields.io/badge/Server_&_Client-30%20%2F%20288-red?style=for-the-badge)

<!-- ![2 / 195](https://progress-bar.dev/2/?scale=195&suffix=%20/%20195&width=300) -->
![Server - 2 / 195](https://img.shields.io/badge/Server-2%20%2F%20195-red?style=for-the-badge)

<!-- ![27 / 93](https://progress-bar.dev/27/?scale=93&suffix=%20/%2093&width=300) -->
![Client - 27 / 93](https://img.shields.io/badge/Client-27%20%2F%2093-red?style=for-the-badge)
<!-- ![28 / 93](https://progress-bar.dev/28/?scale=93&suffix=%20/%2093&width=300) -->
![Client - 28 / 93](https://img.shields.io/badge/Client-28%20%2F%2093-red?style=for-the-badge)

### 🔑 Key
| Icon | Definition |
Expand All @@ -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
<!-- ![29 / 52](https://progress-bar.dev/29/?scale=52&suffix=%20/%2052&width=120) -->
![Account - 29 / 52](https://img.shields.io/badge/Account-29%20%2F%2052-yellow?style=for-the-badge)
<!-- ![30 / 52](https://progress-bar.dev/30/?scale=52&suffix=%20/%2052&width=120) -->
![Account - 30 / 52](https://img.shields.io/badge/Account-30%20%2F%2052-yellow?style=for-the-badge)

| Endpoint | Client | Server |
|:-:|:-:|:-:|
Expand All @@ -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) | ⬛ | ❌ |
Expand Down
17 changes: 17 additions & 0 deletions src/PinguApps.Appwrite.Client/Clients/AccountClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -482,4 +482,21 @@ public async Task<AppwriteResult<Token>> CreatePasswordRecovery(CreatePasswordRe
return e.GetExceptionResponse<Token>();
}
}

/// <inheritdoc/>
public async Task<AppwriteResult<Token>> CreatePasswordRecoveryConfirmation(CreatePasswordRecoveryConfirmationRequest request)
{
try
{
request.Validate(true);

var result = await _accountApi.CreatePasswordRecoveryConfirmation(request);

return result.GetApiResponse();
}
catch (Exception e)
{
return e.GetExceptionResponse<Token>();
}
}
}
11 changes: 10 additions & 1 deletion src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,19 @@ public interface IAccountClient
Task<AppwriteResult<MfaRecoveryCodes>> RegenerateMfaRecoveryCodes();

/// <summary>
/// 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 <see cref="CreatePasswordRecoveryConfirmation(CreatePasswordRecoveryConfirmationRequest)"/> to complete the process. The verification link sent to the user's email address is valid for 1 hour
/// <para><see href="https://appwrite.io/docs/references/1.5.x/client-rest/account#createRecovery">Appwrite Docs</see></para>
/// </summary>
/// <param name="request">The request content</param>
/// <returns>The Token</returns>
Task<AppwriteResult<Token>> CreatePasswordRecovery(CreatePasswordRecoveryRequest request);

/// <summary>
/// <para>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 <see cref="CreatePasswordRecovery(CreatePasswordRecoveryRequest)"/></para>
/// <para>Please note that in order to avoid a <see href="https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.md">Redirect Attack</see> the only valid redirect URLs are the ones from domains you have set when adding your platforms in the console interface</para>
/// <para><see href="https://appwrite.io/docs/references/1.5.x/client-rest/account#updateRecovery">Appwrite Docs</see></para>
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
Task<AppwriteResult<Token>> CreatePasswordRecoveryConfirmation(CreatePasswordRecoveryConfirmationRequest request);
}
3 changes: 3 additions & 0 deletions src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,7 @@ internal interface IAccountApi : IBaseApi

[Post("/account/recovery")]
Task<IApiResponse<Token>> CreatePasswordRecovery(CreatePasswordRecoveryRequest request);

[Put("/account/recovery")]
Task<IApiResponse<Token>> CreatePasswordRecoveryConfirmation(CreatePasswordRecoveryConfirmationRequest request);
}
7 changes: 4 additions & 3 deletions src/PinguApps.Appwrite.Playground/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "[email protected]",
Url = "https://localhost:5001/abc"
UserId = "censored",
Secret = "censored",
Password = "MyNewSuperAwesomePassword!"
});

Console.WriteLine(response.Result.Match(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
using PinguApps.Appwrite.Shared.Requests.Validators;

namespace PinguApps.Appwrite.Shared.Requests;

/// <summary>
/// The request for creating a password recovery confirmation
/// </summary>
public class CreatePasswordRecoveryConfirmationRequest : BaseRequest<CreatePasswordRecoveryConfirmationRequest, CreatePasswordRecoveryConfirmationRequestValidator>
{
/// <summary>
/// User ID
/// </summary>
[JsonPropertyName("userId")]
public string UserId { get; set; } = string.Empty;

/// <summary>
/// Valid reset token
/// </summary>
[JsonPropertyName("secret")]
public string Secret { get; set; } = string.Empty;

/// <summary>
/// New user password. Must be between 8 and 256 chars
/// </summary>
[JsonPropertyName("password")]
public string Password { get; set; } = string.Empty;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using FluentValidation;

namespace PinguApps.Appwrite.Shared.Requests.Validators;
public class CreatePasswordRecoveryConfirmationRequestValidator : AbstractValidator<CreatePasswordRecoveryConfirmationRequest>
{
public CreatePasswordRecoveryConfirmationRequestValidator()
{
RuleFor(x => x.UserId).NotEmpty();
RuleFor(x => x.Secret).NotEmpty();
RuleFor(x => x.Password).NotEmpty().MinimumLength(8).MaximumLength(256);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<ValidationException>(() => 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);
}
}