Skip to content

Commit

Permalink
Merge pull request #129 from PinguApps/96-create-password-recovery
Browse files Browse the repository at this point in the history
Implemented create password recovery
  • Loading branch information
pingu2k4 authored Aug 10, 2024
2 parents f49a309 + 18b7900 commit 9168358
Show file tree
Hide file tree
Showing 9 changed files with 258 additions and 9 deletions.
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
<!-- ![28 / 288](https://progress-bar.dev/28/?scale=288&suffix=%20/%20288&width=500) -->
![Server & Client - 28 / 288](https://img.shields.io/badge/Server_&_Client-28%20%2F%20288-red?style=for-the-badge)
<!-- ![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)

<!-- ![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)

<!-- ![26 / 93](https://progress-bar.dev/26/?scale=93&suffix=%20/%2093&width=300) -->
![Client - 26 / 93](https://img.shields.io/badge/Client-26%20%2F%2093-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)

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

| Endpoint | Client | Server |
|:-:|:-:|:-:|
Expand All @@ -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) |||
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 @@ -465,4 +465,21 @@ public async Task<AppwriteResult<MfaRecoveryCodes>> RegenerateMfaRecoveryCodes()
return e.GetExceptionResponse<MfaRecoveryCodes>();
}
}

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

var result = await _accountApi.CreatePasswordRecovery(request);

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

/// <summary>
/// Regenerate recovery codes that can be used as backup for MFA flow. Before regenerating codes, they must be first generated using <see cref="CreateMfaRecoveryCodes"/> method. An OTP challenge is required to regenreate recovery codes
/// <para><see href="https://appwrite.io/docs/references/1.5.x/client-rest/account#updateMfaRecoveryCodes">Appwrite Docs</see></para>
/// <para><see href="https://appwrite.io/docs/references/1.5.x/client-rest/account#updateMfaRecoveryCodes">Appwrite Docs</see></para>
/// </summary>
/// <returns></returns>
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
/// <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);
}
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 @@ -86,4 +86,7 @@ internal interface IAccountApi : IBaseApi

[Patch("/account/mfa/recovery-codes")]
Task<IApiResponse<MfaRecoveryCodes>> RegenerateMfaRecoveryCodes([Header("x-appwrite-session")] string session);

[Post("/account/recovery")]
Task<IApiResponse<Token>> CreatePasswordRecovery(CreatePasswordRecoveryRequest request);
}
6 changes: 5 additions & 1 deletion src/PinguApps.Appwrite.Playground/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "[email protected]",
Url = "https://localhost:5001/abc"
});

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

namespace PinguApps.Appwrite.Shared.Requests;

/// <summary>
/// The request for creating a password recovery
/// </summary>
public class CreatePasswordRecoveryRequest : BaseRequest<CreatePasswordRecoveryRequest, CreatePasswordRecoveryRequestValidator>
{
/// <summary>
/// User email
/// </summary>
[JsonPropertyName("email")]
public string Email { get; set; } = string.Empty;

/// <summary>
/// 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 <see href="https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html">open redirect</see> attack against your project API
/// </summary>
[JsonPropertyName("url")]
public string Url { get; set; } = string.Empty;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System;
using FluentValidation;

namespace PinguApps.Appwrite.Shared.Requests.Validators;
public class CreatePasswordRecoveryRequestValidator : AbstractValidator<CreatePasswordRecoveryRequest>
{
public CreatePasswordRecoveryRequestValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Url).NotEmpty().Must(uri => Uri.TryCreate(uri, UriKind.Absolute, out _));
}
}
Original file line number Diff line number Diff line change
@@ -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 = "[email protected]",
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 = "[email protected]",
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 = "[email protected]",
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);
}
}
Original file line number Diff line number Diff line change
@@ -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 = "[email protected]";

// 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 = "[email protected]",
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("[email protected]", null)]
[InlineData("[email protected]", "")]
[InlineData("[email protected]", "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<ValidationException>(() => 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);
}
}

0 comments on commit 9168358

Please sign in to comment.