diff --git a/README.md b/README.md index 4baf03fd..49cb279b 100644 --- a/README.md +++ b/README.md @@ -139,11 +139,11 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( ## ⌛ Progress ### Server & Client -![9 / 298](https://progress-bar.dev/9/?scale=298&suffix=%20/%20298&width=500) +![11 / 298](https://progress-bar.dev/11/?scale=298&suffix=%20/%20298&width=500) ### Server Only -![1 / 195](https://progress-bar.dev/1/?scale=195&suffix=%20/%20195&width=300) +![2 / 195](https://progress-bar.dev/2/?scale=195&suffix=%20/%20195&width=300) ### Client Only -![8 / 93](https://progress-bar.dev/8/?scale=93&suffix=%20/%2093&width=300) +![9 / 93](https://progress-bar.dev/9/?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 -![9 / 52](https://progress-bar.dev/9/?scale=52&suffix=%20/%2052&width=120) +![11 / 52](https://progress-bar.dev/11/?scale=52&suffix=%20/%2052&width=120) | Endpoint | Client | Server | |:-:|:-:|:-:| @@ -196,7 +196,7 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [Create Push Target](https://appwrite.io/docs/references/1.5.x/client-rest/account#createPushTarget) | ⬛ | ❌ | | [Update Push Target](https://appwrite.io/docs/references/1.5.x/client-rest/account#updatePushTarget) | ⬛ | ❌ | | [Delete Push Target](https://appwrite.io/docs/references/1.5.x/client-rest/account#deletePushTarget) | ⬛ | ❌ | -| [Create Email Token (OTP)](https://appwrite.io/docs/references/1.5.x/client-rest/account#createEmailToken) | ⬛ | ⬛ | +| [Create Email Token (OTP)](https://appwrite.io/docs/references/1.5.x/client-rest/account#createEmailToken) | ✅ | ✅ | | [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) | ⬛ | ⬛ | diff --git a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs index 4d2d1459..1b5851d4 100644 --- a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs @@ -165,4 +165,21 @@ public async Task> UpdatePreferences(UpdatePreferencesReque return e.GetExceptionResponse(); } } + + /// + public async Task> CreateEmailToken(CreateEmailTokenRequest request) + { + try + { + request.Validate(true); + + var result = await _accountApi.CreateEmailToken(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 4f6d3147..40fc460b 100644 --- a/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs @@ -75,4 +75,13 @@ public interface IAccountClient /// The request content /// The user Task> UpdatePreferences(UpdatePreferencesRequest request); + + /// + /// Sends the user an email with a secret key for creating a session. If the provided user ID has not be registered, a new user will be created. Use the returned user ID and secret and submit a request to the Create Session endpoint to complete the login process. The secret sent to the user's email is valid for 15 minutes. + /// A user is limited to 10 active sessions at a time by default. Learn more about session limits. + /// Appwrite Docs + /// + /// The request content + /// The token + Task> CreateEmailToken(CreateEmailTokenRequest request); } diff --git a/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs b/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs index 3cd2791c..f978dfdb 100644 --- a/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs +++ b/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs @@ -31,4 +31,7 @@ internal interface IAccountApi : IBaseApi [Patch("/account/prefs")] Task> UpdatePreferences([Header("x-appwrite-session")] string? session, UpdatePreferencesRequest request); + + [Post("/account/tokens/email")] + Task> CreateEmailToken(CreateEmailTokenRequest request); } diff --git a/src/PinguApps.Appwrite.Playground/App.cs b/src/PinguApps.Appwrite.Playground/App.cs index 77f17c6e..49aa8b6c 100644 --- a/src/PinguApps.Appwrite.Playground/App.cs +++ b/src/PinguApps.Appwrite.Playground/App.cs @@ -19,24 +19,16 @@ public App(IAppwriteClient client, IAppwriteServer server, IConfiguration config public async Task Run(string[] args) { - _client.SetSession(_session); + //_client.SetSession(_session); - var request = new UpdatePhoneRequest + var request = new CreateEmailTokenRequest { - Password = "sword", - Phone = "14155552671" + Email = "pingu@example.com", + UserId = "664aac1a00113f82e620", + Phrase = true }; - var f = request.IsValid(); - - var result = await _client.Account.UpdatePreferences(new UpdatePreferencesRequest - { - Preferences = new Dictionary - { - { "key1", "val1" }, - { "key2", "val2" } - } - }); + var result = await _server.Account.CreateEmailToken(request); result.Result.Switch( account => Console.WriteLine(string.Join(',', account)), diff --git a/src/PinguApps.Appwrite.Server/Internals/IAccountApi.cs b/src/PinguApps.Appwrite.Server/Internals/IAccountApi.cs index 67b4105e..d867d475 100644 --- a/src/PinguApps.Appwrite.Server/Internals/IAccountApi.cs +++ b/src/PinguApps.Appwrite.Server/Internals/IAccountApi.cs @@ -8,4 +8,7 @@ internal interface IAccountApi : IBaseApi { [Post("/account")] Task> CreateAccount(CreateAccountRequest request); + + [Post("/account/tokens/email")] + Task> CreateEmailToken(CreateEmailTokenRequest request); } diff --git a/src/PinguApps.Appwrite.Server/Servers/AccountServer.cs b/src/PinguApps.Appwrite.Server/Servers/AccountServer.cs index 7279362f..2e270d15 100644 --- a/src/PinguApps.Appwrite.Server/Servers/AccountServer.cs +++ b/src/PinguApps.Appwrite.Server/Servers/AccountServer.cs @@ -17,6 +17,7 @@ public AccountServer(IServiceProvider services) _accountApi = services.GetRequiredService(); } + /// public async Task> Create(CreateAccountRequest request) { try @@ -32,4 +33,21 @@ public async Task> Create(CreateAccountRequest request) return e.GetExceptionResponse(); } } + + /// + public async Task> CreateEmailToken(CreateEmailTokenRequest request) + { + try + { + request.Validate(true); + + var result = await _accountApi.CreateEmailToken(request); + + return result.GetApiResponse(); + } + catch (Exception e) + { + return e.GetExceptionResponse(); + } + } } diff --git a/src/PinguApps.Appwrite.Server/Servers/IAccountServer.cs b/src/PinguApps.Appwrite.Server/Servers/IAccountServer.cs index a9b63d56..a7cea3f1 100644 --- a/src/PinguApps.Appwrite.Server/Servers/IAccountServer.cs +++ b/src/PinguApps.Appwrite.Server/Servers/IAccountServer.cs @@ -20,4 +20,13 @@ public interface IAccountServer /// The request content /// The created user Task> Create(CreateAccountRequest request); + + /// + /// Sends the user an email with a secret key for creating a session. If the provided user ID has not be registered, a new user will be created. Use the returned user ID and secret and submit a request to the Create Session endpoint to complete the login process. The secret sent to the user's email is valid for 15 minutes. + /// A user is limited to 10 active sessions at a time by default. Learn more about session limits. + /// Appwrite Docs + /// + /// The request content + /// The token + Task> CreateEmailToken(CreateEmailTokenRequest request); } diff --git a/src/PinguApps.Appwrite.Shared/Requests/CreateEmailTokenRequest.cs b/src/PinguApps.Appwrite.Shared/Requests/CreateEmailTokenRequest.cs new file mode 100644 index 00000000..df7b8f48 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Requests/CreateEmailTokenRequest.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using PinguApps.Appwrite.Shared.Requests.Validators; +using PinguApps.Appwrite.Shared.Utils; + +namespace PinguApps.Appwrite.Shared.Requests; + +/// +/// The request for creating an email token +/// +public class CreateEmailTokenRequest : BaseRequest +{ + /// + /// User ID. Choose a custom ID or generate a random ID with . Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can't start with a special char. Max length is 36 chars + /// + [JsonPropertyName("userId")] + public string UserId { get; set; } = IdUtils.GenerateUniqueId(); + + /// + /// User email + /// + [JsonPropertyName("email")] + public string Email { get; set; } = string.Empty; + + /// + /// Toggle for security phrase. If enabled, email will be send with a randomly generated phrase and the phrase will also be included in the response. Confirming phrases match increases the security of your authentication flow. + /// + [JsonPropertyName("phrase")] + public bool Phrase { get; set; } = false; +} diff --git a/src/PinguApps.Appwrite.Shared/Requests/Validators/CreateEmailTokenRequestValidator.cs b/src/PinguApps.Appwrite.Shared/Requests/Validators/CreateEmailTokenRequestValidator.cs new file mode 100644 index 00000000..8e7dc051 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Requests/Validators/CreateEmailTokenRequestValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace PinguApps.Appwrite.Shared.Requests.Validators; +public class CreateEmailTokenRequestValidator : AbstractValidator +{ + public CreateEmailTokenRequestValidator() + { + RuleFor(x => x.UserId).NotEmpty().Matches("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,35}$"); + RuleFor(x => x.Email).NotEmpty().EmailAddress(); + } +} diff --git a/src/PinguApps.Appwrite.Shared/Responses/Token.cs b/src/PinguApps.Appwrite.Shared/Responses/Token.cs new file mode 100644 index 00000000..e1f9cf38 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Responses/Token.cs @@ -0,0 +1,22 @@ +using System; +using System.Text.Json.Serialization; + +namespace PinguApps.Appwrite.Shared.Responses; + +/// +/// An Appwrite Token object +/// +/// Token ID +/// Token creation date in ISO 8601 format +/// User ID +/// Token secret key. This will return an empty string unless the response is returned using an API key or as part of a webhook payload +/// Token expiration date in ISO 8601 format +/// Security phrase of a token. Empty if security phrase was not requested when creating a token. It includes randomly generated phrase which is also sent in the external resource such as email +public record Token( + [property: JsonPropertyName("$id")] string Id, + [property: JsonPropertyName("$createdAt")] DateTime CreatedAt, + [property: JsonPropertyName("userId")] string UserId, + [property: JsonPropertyName("secret")] string Secret, + [property: JsonPropertyName("expire")] DateTime ExpiresAt, + [property: JsonPropertyName("phrase")] string Phrase +); diff --git a/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreateEmailToken.cs b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreateEmailToken.cs new file mode 100644 index 00000000..f0b456ec --- /dev/null +++ b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreateEmailToken.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 CreateEmailToken_ShouldReturnSuccess_WhenApiCallSucceeds() + { + // Arrange + var request = new CreateEmailTokenRequest() + { + UserId = "123456", + Email = "email@example.com" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/tokens/email") + .ExpectedHeaders() + .WithJsonContent(request) + .Respond(Constants.AppJson, Constants.TokenResponse); + + // Act + var result = await _appwriteClient.Account.CreateEmailToken(request); + + // Assert + Assert.True(result.Success); + } + + [Fact] + public async Task CreateEmailToken_ShouldHandleException_WhenApiCallFails() + { + // Arrange + var request = new CreateEmailTokenRequest() + { + UserId = "123456", + Email = "email@example.com" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/tokens/email") + .ExpectedHeaders() + .WithJsonContent(request) + .Respond(HttpStatusCode.BadRequest, Constants.AppJson, Constants.AppwriteError); + + // Act + var result = await _appwriteClient.Account.CreateEmailToken(request); + + // Assert + Assert.True(result.IsError); + Assert.True(result.IsAppwriteError); + } + + [Fact] + public async Task CreateEmailToken_ShouldReturnErrorResponse_WhenExceptionOccurs() + { + // Arrange + var request = new CreateEmailTokenRequest() + { + UserId = "123456", + Email = "email@example.com" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/tokens/email") + .ExpectedHeaders() + .WithJsonContent(request) + .Throw(new HttpRequestException("An error occurred")); + + // Act + var result = await _appwriteClient.Account.CreateEmailToken(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.Server.Tests/Servers/Account/AccountServerTests.CreateEmailToken.cs b/tests/PinguApps.Appwrite.Server.Tests/Servers/Account/AccountServerTests.CreateEmailToken.cs new file mode 100644 index 00000000..5e91c88b --- /dev/null +++ b/tests/PinguApps.Appwrite.Server.Tests/Servers/Account/AccountServerTests.CreateEmailToken.cs @@ -0,0 +1,77 @@ +using System.Net; +using PinguApps.Appwrite.Shared.Requests; +using PinguApps.Appwrite.Shared.Tests; +using RichardSzalay.MockHttp; + +namespace PinguApps.Appwrite.Server.Tests.Servers.Account; +public partial class AccountServerTests +{ + [Fact] + public async Task CreateEmailToken_ShouldReturnSuccess_WhenApiCallSucceeds() + { + // Arrange + var request = new CreateEmailTokenRequest() + { + UserId = "123456", + Email = "email@example.com" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/tokens/email") + .ExpectedHeaders() + .WithJsonContent(request) + .Respond(Constants.AppJson, Constants.TokenResponse); + + // Act + var result = await _appwriteServer.Account.CreateEmailToken(request); + + // Assert + Assert.True(result.Success); + } + + [Fact] + public async Task CreateEmailToken_ShouldHandleException_WhenApiCallFails() + { + // Arrange + var request = new CreateEmailTokenRequest() + { + UserId = "123456", + Email = "email@example.com" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/tokens/email") + .ExpectedHeaders() + .WithJsonContent(request) + .Respond(HttpStatusCode.BadRequest, Constants.AppJson, Constants.AppwriteError); + + // Act + var result = await _appwriteServer.Account.CreateEmailToken(request); + + // Assert + Assert.True(result.IsError); + Assert.True(result.IsAppwriteError); + } + + [Fact] + public async Task CreateEmailToken_ShouldReturnErrorResponse_WhenExceptionOccurs() + { + // Arrange + var request = new CreateEmailTokenRequest() + { + UserId = "123456", + Email = "email@example.com" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/tokens/email") + .ExpectedHeaders() + .WithJsonContent(request) + .Throw(new HttpRequestException("An error occurred")); + + // Act + var result = await _appwriteServer.Account.CreateEmailToken(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/Constants.cs b/tests/PinguApps.Appwrite.Shared.Tests/Constants.cs index 34ea4d11..2374dec7 100644 --- a/tests/PinguApps.Appwrite.Shared.Tests/Constants.cs +++ b/tests/PinguApps.Appwrite.Shared.Tests/Constants.cs @@ -79,4 +79,15 @@ public static class Constants "def": "456" } """; + + public const string TokenResponse = """ + { + "$id": "bb8ea5c16897e", + "$createdAt": "2020-10-15T06:38:00.000+00:00", + "userId": "5e5ea5c168bb8", + "secret": "secret", + "expire": "2020-10-15T06:38:00.000+00:00", + "phrase": "Golden Fox" + } + """; } diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreateEmailTokenRequestTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreateEmailTokenRequestTests.cs new file mode 100644 index 00000000..0fcd571d --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreateEmailTokenRequestTests.cs @@ -0,0 +1,116 @@ +using FluentValidation; +using PinguApps.Appwrite.Shared.Requests; + +namespace PinguApps.Appwrite.Shared.Tests.Requests; +public class CreateEmailTokenRequestTests +{ + [Fact] + public void Constructor_InitializesWithExpectedValues() + { + // Arrange & Act + var request = new CreateEmailTokenRequest(); + + // Assert + Assert.NotEmpty(request.UserId); + Assert.Equal(string.Empty, request.Email); + Assert.False(request.Phrase); + } + + [Fact] + public void Properties_CanBeSet() + { + var userId = "123456"; + var email = "test@example.com"; + var phrase = true; + + // Arrange + var request = new CreateEmailTokenRequest(); + + // Act + request.UserId = userId; + request.Email = email; + request.Phrase = phrase; + + // Assert + Assert.Equal(userId, request.UserId); + Assert.Equal(email, request.Email); + Assert.Equal(phrase, request.Phrase); + } + + [Theory] + [InlineData("321654987", "pingu@example.com", true)] + [InlineData("321654987", "pingu@example.com", false)] + public void IsValid_WithValidData_ReturnsTrue(string userId, string email, bool phrase) + { + // Arrange + var request = new CreateEmailTokenRequest + { + UserId = userId, + Email = email, + Phrase = phrase + }; + + // Act + var isValid = request.IsValid(); + + // Assert + Assert.True(isValid); + } + + [Theory] + [InlineData("badChar^", "pingu@example.com", true)] + [InlineData(".bad", "pingu@example.com", true)] + [InlineData("_bad", "pingu@example.com", true)] + [InlineData("-bad", "pingu@example.com", true)] + [InlineData("", "pingu@example.com", true)] + [InlineData("1234567890123456789012345678901234567", "pingu@example.com", true)] + [InlineData("123456", "not an email", true)] + [InlineData("123456", "", true)] + public void IsValid_WithInvalidData_ReturnsFalse(string userId, string email, bool phrase) + { + // Arrange + var request = new CreateEmailTokenRequest + { + UserId = userId, + Email = email, + Phrase = phrase + }; + + // Act + var isValid = request.IsValid(); + + // Assert + Assert.False(isValid); + } + + [Fact] + public void Validate_WithThrowOnFailuresTrue_ThrowsValidationExceptionOnFailure() + { + // Arrange + var request = new CreateEmailTokenRequest + { + UserId = ".badChar^", + Email = "not an email" + }; + + // Assert + Assert.Throws(() => request.Validate(true)); + } + + [Fact] + public void Validate_WithThrowOnFailuresFalse_ReturnsInvalidResultOnFailure() + { + // Arrange + var request = new CreateEmailTokenRequest + { + UserId = ".badChar^", + Email = "not an email" + }; + + // Act + var result = request.Validate(false); + + // Assert + Assert.False(result.IsValid); + } +} diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Responses/TokenTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/Responses/TokenTests.cs new file mode 100644 index 00000000..31fb7547 --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/Responses/TokenTests.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using PinguApps.Appwrite.Shared.Responses; + +namespace PinguApps.Appwrite.Shared.Tests.Responses; +public class TokenTests +{ + [Fact] + public void Token_Constructor_AssignsPropertiesCorrectly() + { + // Arrange + var id = "testId"; + var createdAt = DateTime.UtcNow; + var userId = "userId"; + var secret = "secret token"; + var expiresAt = DateTime.UtcNow; + var phrase = "phrase"; + + + // Act + var token = new Token(id, createdAt, userId, secret, expiresAt, phrase); + + // Assert + Assert.Equal(id, token.Id); + Assert.Equal(createdAt, token.CreatedAt); + Assert.Equal(userId, token.UserId); + Assert.Equal(secret, token.Secret); + Assert.Equal(expiresAt, token.ExpiresAt); + Assert.Equal(phrase, token.Phrase); + } + + [Fact] + public void Token_CanBeDeserialized_FromJson() + { + // Act + var token = JsonSerializer.Deserialize(Constants.TokenResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + // Assert + Assert.NotNull(token); + Assert.Equal("bb8ea5c16897e", token.Id); + Assert.Equal(DateTime.Parse("2020-10-15T06:38:00.000+00:00").ToUniversalTime(), token.CreatedAt.ToUniversalTime()); + Assert.Equal("5e5ea5c168bb8", token.UserId); + Assert.Equal("secret", token.Secret); + Assert.Equal(DateTime.Parse("2020-10-15T06:38:00.000+00:00").ToUniversalTime(), token.ExpiresAt.ToUniversalTime()); + Assert.Equal("Golden Fox", token.Phrase); + } +}