diff --git a/README.md b/README.md index 5188eef8..19aa45a7 100644 --- a/README.md +++ b/README.md @@ -139,11 +139,11 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( ## ⌛ Progress ### Server & Client -![14 / 288](https://progress-bar.dev/14/?scale=288&suffix=%20/%20288&width=500) +![15 / 288](https://progress-bar.dev/15/?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 -![12 / 93](https://progress-bar.dev/12/?scale=93&suffix=%20/%2093&width=300) +![13 / 93](https://progress-bar.dev/13/?scale=93&suffix=%20/%2093&width=300) ### 🔑 Key | Icon | Definition | @@ -153,7 +153,7 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | ❌ | There is currently no intention to implement the endpoint for the given SDK type (client or server) | ### Account -![14 / 52](https://progress-bar.dev/14/?scale=52&suffix=%20/%2052&width=120) +![15 / 52](https://progress-bar.dev/15/?scale=52&suffix=%20/%2052&width=120) | Endpoint | Client | Server | |:-:|:-:|:-:| @@ -200,7 +200,7 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [Create Magic URL Token](https://appwrite.io/docs/references/1.5.x/client-rest/account#createMagicURLToken) | ⬛ | ⬛ | | [Create OAuth2 Token](https://appwrite.io/docs/references/1.5.x/client-rest/account#createOAuth2Token) | ⬛ | ⬛ | | [Create Phone Token](https://appwrite.io/docs/references/1.5.x/client-rest/account#createPhoneToken) | ⬛ | ⬛ | -| [Create Email Verification](https://appwrite.io/docs/references/1.5.x/client-rest/account#createVerification) | ⬛ | ❌ | +| [Create Email Verification](https://appwrite.io/docs/references/1.5.x/client-rest/account#createVerification) | ✅ | ❌ | | [Create Email Verification (Confirmation)](https://appwrite.io/docs/references/1.5.x/client-rest/account#updateVerification) | ⬛ | ❌ | | [Create Phone Verification](https://appwrite.io/docs/references/1.5.x/client-rest/account#createPhoneVerification) | ⬛ | ❌ | | [Create Phone Verification (Confirmation)](https://appwrite.io/docs/references/1.5.x/client-rest/account#updatePhoneVerification) | ⬛ | ❌ | diff --git a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs index 3cc3748a..88e6bfe0 100644 --- a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs @@ -229,4 +229,21 @@ public async Task> UpdateSession(string sessionId = "cur return e.GetExceptionResponse(); } } + + /// + public async Task> CreateEmailVerification(CreateEmailVerificationRequest request) + { + try + { + request.Validate(true); + + var result = await _accountApi.CreateEmailVerification(Session, 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 b05c29aa..3d53e61b 100644 --- a/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs @@ -108,4 +108,13 @@ public interface IAccountClient /// Session ID. Use the string 'current' to update the current device session. /// The session Task> UpdateSession(string sessionId = "current"); + + /// + /// Use this endpoint to send a verification message to your user email address to confirm they are the valid owners of that address. Both the userId and secret arguments will be passed as query parameters to the URL you have provided to be attached to the verification email. The provided URL should redirect the user back to your app and allow you to complete the verification process by verifying both the userId and secret parameters. Learn more about how to complete the verification process. The verification link sent to the user's email address is valid for 7 days. + /// 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 + /// + /// The request content + /// The token + Task> CreateEmailVerification(CreateEmailVerificationRequest request); } diff --git a/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs b/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs index bf8a9164..2985dd38 100644 --- a/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs +++ b/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs @@ -43,4 +43,7 @@ internal interface IAccountApi : IBaseApi [Patch("/account/sessions/{sessionId}")] Task> UpdateSession([Header("x-appwrite-session")] string? session, string sessionId); + + [Post(("/account/verification"))] + Task> CreateEmailVerification([Header("x-appwrite-session")] string? session, CreateEmailVerificationRequest request); } diff --git a/src/PinguApps.Appwrite.Playground/App.cs b/src/PinguApps.Appwrite.Playground/App.cs index 6f70b111..f0d5173c 100644 --- a/src/PinguApps.Appwrite.Playground/App.cs +++ b/src/PinguApps.Appwrite.Playground/App.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using PinguApps.Appwrite.Client; using PinguApps.Appwrite.Server.Servers; +using PinguApps.Appwrite.Shared.Requests; namespace PinguApps.Appwrite.Playground; internal class App @@ -20,32 +21,16 @@ public async Task Run(string[] args) { _client.SetSession(_session); - Console.WriteLine("Getting Session..."); + var request = new CreateEmailVerificationRequest + { + Url = "https://localhost:5001/abc123" + }; - //var response = await _client.Account.CreateEmailToken(new CreateEmailTokenRequest - //{ - // Email = "pingu@pinguapps.com", - // UserId = "664aac1a00113f82e620" - //}); - - //var response = await _client.Account.CreateSession(new CreateSessionRequest - //{ - // UserId = "664aac1a00113f82e620", - // Secret = "623341" - //}); - - var response = await _client.Account.GetSession("66a810f2e55b1329e25b"); - - var response2 = await _client.Account.UpdateSession("66a810f2e55b1329e25b"); + var response = await _client.Account.CreateEmailVerification(request); Console.WriteLine(response.Result.Match( account => account.ToString(), appwriteError => appwriteError.Message, internalERror => internalERror.Message)); - - Console.WriteLine(response2.Result.Match( - account => account.ToString(), - appwriteError => appwriteError.Message, - internalERror => internalERror.Message)); } } diff --git a/src/PinguApps.Appwrite.Shared/Requests/CreateEmailVerificationRequest.cs b/src/PinguApps.Appwrite.Shared/Requests/CreateEmailVerificationRequest.cs new file mode 100644 index 00000000..c4f215a6 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Requests/CreateEmailVerificationRequest.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using PinguApps.Appwrite.Shared.Requests.Validators; + +namespace PinguApps.Appwrite.Shared.Requests; + +/// +/// The request for creating an email verification (will email the user a link to verify their email) +/// +public class CreateEmailVerificationRequest : BaseRequest +{ + /// + /// URL to redirect the user back to your app from the verification 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/CreateEmailVerificationRequestValidator.cs b/src/PinguApps.Appwrite.Shared/Requests/Validators/CreateEmailVerificationRequestValidator.cs new file mode 100644 index 00000000..be9dcd0b --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Requests/Validators/CreateEmailVerificationRequestValidator.cs @@ -0,0 +1,11 @@ +using System; +using FluentValidation; + +namespace PinguApps.Appwrite.Shared.Requests.Validators; +public class CreateEmailVerificationRequestValidator : AbstractValidator +{ + public CreateEmailVerificationRequestValidator() + { + RuleFor(x => x.Url).NotEmpty().Must(uri => Uri.TryCreate(uri, UriKind.Absolute, out _)); + } +} diff --git a/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreateEmailVerification.cs b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreateEmailVerification.cs new file mode 100644 index 00000000..e9781649 --- /dev/null +++ b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreateEmailVerification.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 CreateEmailVerification_ShouldReturnSuccess_WhenApiCallSucceeds() + { + // Arrange + var request = new CreateEmailVerificationRequest() + { + Url = "https://localhost:5001/abc123" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/verification") + .ExpectedHeaders(true) + .WithJsonContent(request) + .Respond(Constants.AppJson, Constants.TokenResponse); + + _appwriteClient.SetSession(Constants.Session); + + // Act + var result = await _appwriteClient.Account.CreateEmailVerification(request); + + // Assert + Assert.True(result.Success); + } + + [Fact] + public async Task CreateEmailVerification_ShouldHandleException_WhenApiCallFails() + { + // Arrange + var request = new CreateEmailVerificationRequest() + { + Url = "https://localhost:5001/abc123" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/verification") + .ExpectedHeaders(true) + .WithJsonContent(request) + .Respond(HttpStatusCode.BadRequest, Constants.AppJson, Constants.AppwriteError); + + _appwriteClient.SetSession(Constants.Session); + + // Act + var result = await _appwriteClient.Account.CreateEmailVerification(request); + + // Assert + Assert.True(result.IsError); + Assert.True(result.IsAppwriteError); + } + + [Fact] + public async Task CreateEmailVerification_ShouldReturnErrorResponse_WhenExceptionOccurs() + { + // Arrange + var request = new CreateEmailVerificationRequest() + { + Url = "https://localhost:5001/abc123" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/verification") + .ExpectedHeaders(true) + .WithJsonContent(request) + .Throw(new HttpRequestException("An error occurred")); + + _appwriteClient.SetSession(Constants.Session); + + // Act + var result = await _appwriteClient.Account.CreateEmailVerification(request); + + // 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/Requests/CreateEmailVerificationRequestTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreateEmailVerificationRequestTests.cs new file mode 100644 index 00000000..255af5a6 --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreateEmailVerificationRequestTests.cs @@ -0,0 +1,97 @@ +using FluentValidation; +using PinguApps.Appwrite.Shared.Requests; + +namespace PinguApps.Appwrite.Shared.Tests.Requests; +public class CreateEmailVerificationRequestTests +{ + [Fact] + public void Constructor_InitializesWithExpectedValues() + { + // Arrange & Act + var request = new CreateEmailVerificationRequest(); + + // Assert + Assert.NotNull(request.Url); + Assert.Equal(string.Empty, request.Url); + } + + [Theory] + [InlineData("https://google.com")] + [InlineData("https://localhost:1234")] + public void Properties_CanBeSet(string url) + { + // Arrange + var request = new CreateEmailVerificationRequest(); + + // Act + request.Url = url; + + // Assert + Assert.Equal(url, request.Url); + } + + [Theory] + [InlineData("https://google.com")] + [InlineData("https://localhost:1234")] + public void IsValid_WithValidData_ReturnsTrue(string url) + { + // Arrange + var request = new CreateEmailVerificationRequest + { + Url = url + }; + + // Act + var isValid = request.IsValid(); + + // Assert + Assert.True(isValid); + } + + [Theory] + [InlineData("")] + [InlineData("not a url")] + public void IsValid_WithInvalidData_ReturnsFalse(string url) + { + // Arrange + var request = new CreateEmailVerificationRequest + { + Url = url + }; + + // Act + var isValid = request.IsValid(); + + // Assert + Assert.False(isValid); + } + + [Fact] + public void Validate_WithThrowOnFailuresTrue_ThrowsValidationExceptionOnFailure() + { + // Arrange + var request = new CreateEmailVerificationRequest + { + Url = "" + }; + + // Assert + Assert.Throws(() => request.Validate(true)); + } + + [Fact] + public void Validate_WithThrowOnFailuresFalse_ReturnsInvalidResultOnFailure() + { + // Arrange + var request = new CreateEmailVerificationRequest + { + Url = "" + }; + + // Act + var result = request.Validate(false); + + // Assert + Assert.False(result.IsValid); + } +}