diff --git a/README.md b/README.md index 49cb279b..6b5a6bb9 100644 --- a/README.md +++ b/README.md @@ -139,11 +139,11 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( ## ⌛ Progress ### Server & Client -![11 / 298](https://progress-bar.dev/11/?scale=298&suffix=%20/%20298&width=500) +![12 / 298](https://progress-bar.dev/12/?scale=298&suffix=%20/%20298&width=500) ### Server Only ![2 / 195](https://progress-bar.dev/2/?scale=195&suffix=%20/%20195&width=300) ### Client Only -![9 / 93](https://progress-bar.dev/9/?scale=93&suffix=%20/%2093&width=300) +![10 / 93](https://progress-bar.dev/10/?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 -![11 / 52](https://progress-bar.dev/11/?scale=52&suffix=%20/%2052&width=120) +![12 / 52](https://progress-bar.dev/12/?scale=52&suffix=%20/%2052&width=120) | Endpoint | Client | Server | |:-:|:-:|:-:| @@ -188,7 +188,7 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [Update Magic URL Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#updateMagicURLSession) | ⬛ | ❌ | | [Create OAuth2 Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#createOAuth2Session) | ⬛ | ❌ | | [Update Phone Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#updatePhoneSession) | ⬛ | ❌ | -| [Create Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#createSession) | ⬛ | ❌ | +| [Create Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#createSession) | ✅ | ❌ | | [Get Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#getSession) | ⬛ | ❌ | | [Update Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#updateSession) | ⬛ | ❌ | | [Delete Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#deleteSession) | ⬛ | ❌ | diff --git a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs index 1b5851d4..50010aa1 100644 --- a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs @@ -182,4 +182,21 @@ public async Task> CreateEmailToken(CreateEmailTokenReques return e.GetExceptionResponse(); } } + + /// + public async Task> CreateSession(CreateSessionRequest request) + { + try + { + request.Validate(true); + + var result = await _accountApi.CreateSession(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 40fc460b..b2f63233 100644 --- a/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs @@ -84,4 +84,12 @@ public interface IAccountClient /// The request content /// The token Task> CreateEmailToken(CreateEmailTokenRequest request); + + /// + /// Use this endpoint to create a session from token. Provide the userId and secret parameters from the successful response of authentication flows initiated by token creation. For example, magic URL and phone login. + /// Appwrite Docs + /// + /// The request content + /// The session + Task> CreateSession(CreateSessionRequest request); } diff --git a/src/PinguApps.Appwrite.Client/Handlers/ClientCookieSessionHandler.cs b/src/PinguApps.Appwrite.Client/Handlers/ClientCookieSessionHandler.cs new file mode 100644 index 00000000..ba52b591 --- /dev/null +++ b/src/PinguApps.Appwrite.Client/Handlers/ClientCookieSessionHandler.cs @@ -0,0 +1,70 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using PinguApps.Appwrite.Client.Internals; + +namespace PinguApps.Appwrite.Client.Handlers; +internal class ClientCookieSessionHandler : DelegatingHandler +{ + private readonly Lazy _appwriteClient; + + public ClientCookieSessionHandler(Lazy appwriteClient) + { + _appwriteClient = appwriteClient; + } + + private IAppwriteClient AppwriteClient => _appwriteClient.Value; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var result = await base.SendAsync(request, cancellationToken); + + SaveSession(result); + + return result; + } + + private void SaveSession(HttpResponseMessage response) + { + if (response.IsSuccessStatusCode) + { + if (response.Headers.TryGetValues("Set-Cookie", out var values)) + { + var sessionCookie = values.FirstOrDefault(x => x.StartsWith("a_session", StringComparison.OrdinalIgnoreCase) && !x.Contains("legacy", StringComparison.OrdinalIgnoreCase)); + + if (sessionCookie is null) + return; + + var afterEquals = sessionCookie.IndexOf('=') + 1; + var semicolonIndex = sessionCookie.IndexOf(';', afterEquals); + var base64 = sessionCookie.Substring(afterEquals, semicolonIndex - afterEquals); + + if (string.Equals(base64, "deleted", StringComparison.OrdinalIgnoreCase)) + { + AppwriteClient.SetSession(null); + return; + } + + var decodedBytes = Convert.FromBase64String(base64); + var decoded = Encoding.UTF8.GetString(decodedBytes); + + try + { + var sessionData = JsonSerializer.Deserialize(decoded); + + if (sessionData is null || sessionData.Id is null || sessionData.Secret is null) + return; + + AppwriteClient.SetSession(sessionData.Secret); + } + catch (JsonException) + { + } + } + } + } +} diff --git a/src/PinguApps.Appwrite.Client/Internals/CookieSessionData.cs b/src/PinguApps.Appwrite.Client/Internals/CookieSessionData.cs new file mode 100644 index 00000000..ee251ca1 --- /dev/null +++ b/src/PinguApps.Appwrite.Client/Internals/CookieSessionData.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace PinguApps.Appwrite.Client.Internals; +internal class CookieSessionData +{ + [JsonPropertyName("id")] + public string Id { get; set; } = default!; + + [JsonPropertyName("secret")] + public string Secret { get; set; } = default!; +} diff --git a/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs b/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs index f978dfdb..6fb39098 100644 --- a/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs +++ b/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs @@ -34,4 +34,7 @@ internal interface IAccountApi : IBaseApi [Post("/account/tokens/email")] Task> CreateEmailToken(CreateEmailTokenRequest request); + + [Post("/account/sessions/token")] + Task> CreateSession(CreateSessionRequest request); } diff --git a/src/PinguApps.Appwrite.Client/ServiceCollectionExtensions.cs b/src/PinguApps.Appwrite.Client/ServiceCollectionExtensions.cs index 339b00bd..d83ece5b 100644 --- a/src/PinguApps.Appwrite.Client/ServiceCollectionExtensions.cs +++ b/src/PinguApps.Appwrite.Client/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Net.Http; using Microsoft.Extensions.DependencyInjection; using PinguApps.Appwrite.Client.Handlers; using PinguApps.Appwrite.Client.Internals; @@ -21,20 +22,23 @@ public static class ServiceCollectionExtensions /// The service collection, enabling chaining public static IServiceCollection AddAppwriteClient(this IServiceCollection services, string projectId, string endpoint = "https://cloud.appwrite.io/v1", RefitSettings? refitSettings = null) { - services.AddSingleton(sp => new HeaderHandler(projectId)); + services.AddSingleton(x => new HeaderHandler(projectId)); + services.AddSingleton(); services.AddRefitClient(refitSettings) .ConfigureHttpClient(x => x.BaseAddress = new Uri(endpoint)) - .AddHttpMessageHandler(); + .AddHttpMessageHandler() + .AddHttpMessageHandler(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(x => new Lazy(() => x.GetRequiredService())); return services; } /// - /// Adds all necessary components for the Client SDK in a transient state. Best used on server-side to perform client SDK abilities on behalf of users + /// Adds all necessary components for the Client SDK such that session will not be remembered. Best used on server-side to perform client SDK abilities on behalf of users /// /// The service collection to add to /// Your Appwrite Project ID @@ -47,10 +51,17 @@ public static IServiceCollection AddAppwriteClientForServer(this IServiceCollect services.AddRefitClient(refitSettings) .ConfigureHttpClient(x => x.BaseAddress = new Uri(endpoint)) - .AddHttpMessageHandler(); + .AddHttpMessageHandler() + .ConfigurePrimaryHttpMessageHandler((handler, sp) => + { + if (handler is HttpClientHandler clientHandler) + { + clientHandler.UseCookies = false; + } + }); - services.AddTransient(); - services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/src/PinguApps.Appwrite.Playground/App.cs b/src/PinguApps.Appwrite.Playground/App.cs index 49aa8b6c..6bc62e81 100644 --- a/src/PinguApps.Appwrite.Playground/App.cs +++ b/src/PinguApps.Appwrite.Playground/App.cs @@ -21,19 +21,31 @@ public async Task Run(string[] args) { //_client.SetSession(_session); - var request = new CreateEmailTokenRequest + var request = new CreateSessionRequest { - Email = "pingu@example.com", UserId = "664aac1a00113f82e620", - Phrase = true + Secret = "834938" }; - var result = await _server.Account.CreateEmailToken(request); + Console.WriteLine($"Session: {_client.Session}"); + + var result = await _client.Account.CreateSession(request); + + Console.WriteLine($"Session: {_client.Session}"); result.Result.Switch( account => Console.WriteLine(string.Join(',', account)), appwriteError => Console.WriteLine(appwriteError.Message), internalError => Console.WriteLine(internalError.Message) ); + + Console.WriteLine("Getting Account..."); + + var account = await _client.Account.Get(); + + Console.WriteLine(account.Result.Match( + account => account.ToString(), + appwriteError => appwriteError.Message, + internalERror => internalERror.Message)); } } diff --git a/src/PinguApps.Appwrite.Shared/Converters/NullableDateTimeConverter.cs b/src/PinguApps.Appwrite.Shared/Converters/NullableDateTimeConverter.cs new file mode 100644 index 00000000..2719aeca --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Converters/NullableDateTimeConverter.cs @@ -0,0 +1,36 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PinguApps.Appwrite.Shared.Converters; +internal class NullableDateTimeConverter : JsonConverter +{ + public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + var stringValue = reader.GetString(); + if (string.IsNullOrEmpty(stringValue)) + { + return null; + } + + if (DateTime.TryParse(stringValue, out var dateTime)) + { + return dateTime; + } + + throw new JsonException($"Unable to parse '{stringValue}' to DateTime."); + } + + throw new JsonException("Unexpected token type."); + } + + public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) + { + if (value.HasValue) + { + writer.WriteStringValue(value.Value.ToString("o")); + } + } +} diff --git a/src/PinguApps.Appwrite.Shared/Requests/CreateSessionRequest.cs b/src/PinguApps.Appwrite.Shared/Requests/CreateSessionRequest.cs new file mode 100644 index 00000000..cd956f78 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Requests/CreateSessionRequest.cs @@ -0,0 +1,23 @@ +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 a session +/// +public class CreateSessionRequest : 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(); + + /// + /// Secret of a token generated by login methods. For example, the CreateMagicURLToken or CreatePhoneToken methods. + /// + [JsonPropertyName("secret")] + public string Secret { get; set; } = string.Empty; +} diff --git a/src/PinguApps.Appwrite.Shared/Requests/Validators/CreateSessionRequestValidator.cs b/src/PinguApps.Appwrite.Shared/Requests/Validators/CreateSessionRequestValidator.cs new file mode 100644 index 00000000..75dbcbe8 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Requests/Validators/CreateSessionRequestValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace PinguApps.Appwrite.Shared.Requests.Validators; +public class CreateSessionRequestValidator : AbstractValidator +{ + public CreateSessionRequestValidator() + { + RuleFor(x => x.UserId).NotEmpty().Matches("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,35}$"); + RuleFor(x => x.Secret).NotEmpty(); + } +} diff --git a/src/PinguApps.Appwrite.Shared/Responses/Session.cs b/src/PinguApps.Appwrite.Shared/Responses/Session.cs new file mode 100644 index 00000000..15f3f71f --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Responses/Session.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using PinguApps.Appwrite.Shared.Converters; + +namespace PinguApps.Appwrite.Shared.Responses; + +/// +/// An Appwrite Session object +/// +/// Session ID +/// Session creation date in ISO 8601 format +/// Session update date in ISO 8601 format +/// User ID +/// Session expiration date in ISO 8601 format +/// Session Provider +/// Session Provider User ID +/// Session Provider Access Token +/// The date of when the access token expires in ISO 8601 format +/// Session Provider Refresh Token +/// IP in use when the session was created +/// Operating system code name. View list of available options +/// Operating system name +/// Operating system version +/// Client type +/// Client code name. View list of available options +/// Client name +/// Client version +/// Client engine name +/// Client engine name +/// Device name +/// Device brand name +/// Device model name +/// Country two-character ISO 3166-1 alpha code +/// Country name +/// Returns true if this the current user session +/// Returns a list of active session factors +/// Secret used to authenticate the user. Only included if the request was made with an API key +/// Most recent date in ISO 8601 format when the session successfully passed MFA challenge +public record Session( + [property: JsonPropertyName("$id")] string Id, + [property: JsonPropertyName("$createdAt")] DateTime CreatedAt, + [property: JsonPropertyName("$updatedAt")] DateTime UpdatedAt, + [property: JsonPropertyName("userId")] string UserId, + [property: JsonPropertyName("expire")] DateTime ExpiresAt, + [property: JsonPropertyName("provider")] string Provider, + [property: JsonPropertyName("providerUid")] string ProviderUserId, + [property: JsonPropertyName("providerAccessToken")] string ProviderAccessToken, + [property: JsonPropertyName("providerAccessTokenExpiry"), JsonConverter(typeof(NullableDateTimeConverter))] DateTime? ProviderAccessTokenExpiry, + [property: JsonPropertyName("providerRefreshToken")] string ProviderRefreshToken, + [property: JsonPropertyName("ip")] string Ip, + [property: JsonPropertyName("osCode")] string OsCode, + [property: JsonPropertyName("osName")] string OsName, + [property: JsonPropertyName("osVersion")] string OsVersion, + [property: JsonPropertyName("clientType")] string ClientType, + [property: JsonPropertyName("clientCode")] string ClientCode, + [property: JsonPropertyName("clientName")] string ClientName, + [property: JsonPropertyName("clientVersion")] string ClientVersion, + [property: JsonPropertyName("clientEngine")] string ClientEngine, + [property: JsonPropertyName("clientEngineVersion")] string ClientEngineVersion, + [property: JsonPropertyName("deviceName")] string DeviceName, + [property: JsonPropertyName("deviceBrand")] string DeviceBrand, + [property: JsonPropertyName("deviceModel")] string DeviceModel, + [property: JsonPropertyName("countryCode")] string CountryCode, + [property: JsonPropertyName("countryName")] string CountryName, + [property: JsonPropertyName("current")] bool Current, + [property: JsonPropertyName("factors")] IReadOnlyList Factors, + [property: JsonPropertyName("secret")] string Secret, + [property: JsonPropertyName("mfaUpdatedAt"), JsonConverter(typeof(NullableDateTimeConverter))] DateTime? MfaUpdatedAt +); diff --git a/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreateSesssion.cs b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreateSesssion.cs new file mode 100644 index 00000000..9c285c37 --- /dev/null +++ b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreateSesssion.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 CreateSession_ShouldReturnSuccess_WhenApiCallSucceeds() + { + // Arrange + var request = new CreateSessionRequest() + { + UserId = "123456", + Secret = "654321" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/sessions/token") + .ExpectedHeaders() + .WithJsonContent(request) + .Respond(Constants.AppJson, Constants.SessionResponse); + + // Act + var result = await _appwriteClient.Account.CreateSession(request); + + // Assert + Assert.True(result.Success); + } + + [Fact] + public async Task CreateSession_ShouldHandleException_WhenApiCallFails() + { + // Arrange + var request = new CreateSessionRequest() + { + UserId = "123456", + Secret = "654321" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/sessions/token") + .ExpectedHeaders() + .WithJsonContent(request) + .Respond(HttpStatusCode.BadRequest, Constants.AppJson, Constants.AppwriteError); + + // Act + var result = await _appwriteClient.Account.CreateSession(request); + + // Assert + Assert.True(result.IsError); + Assert.True(result.IsAppwriteError); + } + + [Fact] + public async Task CreateSession_ShouldReturnErrorResponse_WhenExceptionOccurs() + { + // Arrange + var request = new CreateSessionRequest() + { + UserId = "123456", + Secret = "654321" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/sessions/token") + .ExpectedHeaders() + .WithJsonContent(request) + .Throw(new HttpRequestException("An error occurred")); + + // Act + var result = await _appwriteClient.Account.CreateSession(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.Client.Tests/Handlers/ClientCookieSessionHandlerTests.cs b/tests/PinguApps.Appwrite.Client.Tests/Handlers/ClientCookieSessionHandlerTests.cs new file mode 100644 index 00000000..ac7ad142 --- /dev/null +++ b/tests/PinguApps.Appwrite.Client.Tests/Handlers/ClientCookieSessionHandlerTests.cs @@ -0,0 +1,176 @@ +using System.Text; +using Moq; +using PinguApps.Appwrite.Client.Handlers; +using PinguApps.Appwrite.Client.Internals; +using RichardSzalay.MockHttp; + +namespace PinguApps.Appwrite.Client.Tests.Handlers; +public class ClientCookieSessionHandlerTests +{ + private readonly MockHttpMessageHandler _mockHttp; + private readonly Mock _mockAppwriteClient; + private readonly HttpClient _httpClient; + + public ClientCookieSessionHandlerTests() + { + _mockHttp = new MockHttpMessageHandler(); + _mockAppwriteClient = new Mock(); + var handler = new ClientCookieSessionHandler(new Lazy(() => _mockAppwriteClient.Object)) + { + InnerHandler = _mockHttp + }; + _httpClient = new HttpClient(handler); + } + + [Fact] + public async Task SendAsync_WhenResponseHasSessionCookie_SavesSessionCorrectly() + { + // Arrange + var sessionData = new CookieSessionData { Id = "123456", Secret = "test_secret" }; + var encodedSessionData = Convert.ToBase64String(Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(sessionData))); + var cookieValue = $"a_session={encodedSessionData}; Path=/; HttpOnly"; + + _mockHttp.When(HttpMethod.Get, "http://test.com") + .Respond(req => + new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Headers = { { "Set-Cookie", cookieValue } } + }); + + // Act + await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://test.com")); + + // Assert + _mockAppwriteClient.Verify(client => client.SetSession("test_secret"), Times.Once); + } + + [Fact] + public async Task SendAsync_NoSetCookieHeader_DoesNotSetSession() + { + // Arrange + + _mockHttp.When(HttpMethod.Get, "http://test.com") + .Respond(req => + new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK + }); + + // Act + await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://test.com")); + + // Assert + _mockAppwriteClient.Verify(client => client.SetSession(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SendAsync_NoASessionCookie_DoesNotSetSession() + { + // Arrange + _mockHttp.When(HttpMethod.Get, "http://test.com") + .Respond(req => + new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Headers = { { "Set-Cookie", "not_a_session=abc123; Path=/; HttpOnly" } } + }); + + // Act + await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://test.com")); + + // Assert + _mockAppwriteClient.Verify(client => client.SetSession(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SendAsync_InvalidBase64InASessionCookie_DoesNotSetSession() + { + // Arrange + var invalidBase64 = "not_base64"; + _mockHttp.When(HttpMethod.Get, "http://test.com") + .Respond(req => + new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Headers = { { "Set-Cookie", $"not_a_session={invalidBase64}; Path=/; HttpOnly" } } + }); + + // Act + await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://test.com")); + + // Assert + _mockAppwriteClient.Verify(client => client.SetSession(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SendAsync_InvalidJsonInDecodedBase64_DoesNotSetSession() + { + // Arrange + var invalidJsonBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"SomeKey\": \"SomeValue\"}")); + _mockHttp.When(HttpMethod.Get, "http://test.com") + .Respond(req => + new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Headers = { { "Set-Cookie", $"a_session={invalidJsonBase64}; Path=/; HttpOnly" } } + }); + + // Act + await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://test.com")); + + // Assert + _mockAppwriteClient.Verify(client => client.SetSession(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SendAsync_SessionCookieMarkedAsDeleted_ClearsSession() + { + // Arrange + var deletedSessionCookie = "a_session=deleted; Path=/; HttpOnly"; + _mockHttp.When(HttpMethod.Get, "http://test.com") + .Respond(req => + new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Headers = { { "Set-Cookie", deletedSessionCookie } } + }); + + // Act + await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://test.com")); + + // Assert + _mockAppwriteClient.Verify(client => client.SetSession(null), Times.Once); + } + + [Fact] + public async Task SendAsync_InvalidJsonInSessionCookie_DoesNotThrowJsonException() + { + // Arrange + var invalidJsonBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("invalid_json")); + var invalidJsonSessionCookie = $"a_session={invalidJsonBase64}; Path=/; HttpOnly"; + _mockHttp.When(HttpMethod.Get, "http://test.com") + .Respond(req => + new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Headers = { { "Set-Cookie", invalidJsonSessionCookie } } + }); + + Exception? caughtException = null; + + try + { + // Act + await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://test.com")); + } + catch (Exception ex) + { + caughtException = ex; + } + + // Assert + Assert.Null(caughtException); + _mockAppwriteClient.Verify(client => client.SetSession(It.IsAny()), Times.Never); + } +} diff --git a/tests/PinguApps.Appwrite.Client.Tests/ServiceCollectionExtensionsTests.cs b/tests/PinguApps.Appwrite.Client.Tests/ServiceCollectionExtensionsTests.cs index 1f93b2cb..f1d65270 100644 --- a/tests/PinguApps.Appwrite.Client.Tests/ServiceCollectionExtensionsTests.cs +++ b/tests/PinguApps.Appwrite.Client.Tests/ServiceCollectionExtensionsTests.cs @@ -18,21 +18,26 @@ public void AddAppwriteClient_RegistersExpectedServices() // Assert var provider = services.BuildServiceProvider(); - // Assert HeaderHandler is registered var headerHandler = provider.GetService(); Assert.NotNull(headerHandler); - // Assert IAccountApi is registered and configured + var clientCookieSessionHandler = provider.GetService(); + Assert.NotNull(clientCookieSessionHandler); + var accountApi = provider.GetService(); Assert.NotNull(accountApi); - // Assert services are registered Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); + + var lazyClient = provider.GetService>(); + Assert.NotNull(lazyClient); + var client = lazyClient.Value; + Assert.NotNull(client); } [Fact] - public void AddAppwriteClientForServer_RegistersExpectedServicesWithTransientLifetime() + public void AddAppwriteClientForServer_RegistersExpectedServices() { // Arrange var services = new ServiceCollection(); @@ -43,21 +48,16 @@ public void AddAppwriteClientForServer_RegistersExpectedServicesWithTransientLif // Assert var provider = services.BuildServiceProvider(); - // Assert HeaderHandler is registered var headerHandler = provider.GetService(); Assert.NotNull(headerHandler); - // Assert IAccountApi is registered and configured + var clientCookieSessionHandler = provider.GetService(); + Assert.Null(clientCookieSessionHandler); + var accountApi = provider.GetService(); Assert.NotNull(accountApi); - // Assert services are registered with Transient lifetime - var accountClientServiceDescriptor = services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(IAccountClient)); - Assert.NotNull(accountClientServiceDescriptor); - Assert.Equal(ServiceLifetime.Transient, accountClientServiceDescriptor.Lifetime); - - var appwriteClientServiceDescriptor = services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(IAppwriteClient)); - Assert.NotNull(appwriteClientServiceDescriptor); - Assert.Equal(ServiceLifetime.Transient, appwriteClientServiceDescriptor.Lifetime); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); } } diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Constants.cs b/tests/PinguApps.Appwrite.Shared.Tests/Constants.cs index 2374dec7..aefef2fb 100644 --- a/tests/PinguApps.Appwrite.Shared.Tests/Constants.cs +++ b/tests/PinguApps.Appwrite.Shared.Tests/Constants.cs @@ -90,4 +90,40 @@ public static class Constants "phrase": "Golden Fox" } """; + + public const string SessionResponse = """ + { + "$id": "5e5ea5c16897e", + "$createdAt": "2020-10-15T06:38:00.000+00:00", + "$updatedAt": "2020-10-15T06:38:00.000+00:00", + "userId": "5e5bb8c16897e", + "expire": "2020-10-15T06:38:00.000+00:00", + "provider": "email", + "providerUid": "user@example.com", + "providerAccessToken": "MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", + "providerAccessTokenExpiry": "2020-10-15T06:38:00.000+00:00", + "providerRefreshToken": "MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", + "ip": "127.0.0.1", + "osCode": "Mac", + "osName": "Mac", + "osVersion": "Mac", + "clientType": "browser", + "clientCode": "CM", + "clientName": "Chrome Mobile iOS", + "clientVersion": "84.0", + "clientEngine": "WebKit", + "clientEngineVersion": "605.1.15", + "deviceName": "smartphone", + "deviceBrand": "Google", + "deviceModel": "Nexus 5", + "countryCode": "US", + "countryName": "United States", + "current": true, + "factors": [ + "email" + ], + "secret": "5e5bb8c16897e", + "mfaUpdatedAt": "2020-10-15T06:38:00.000+00:00" + } + """; } diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Converters/NullableDateTimeConverterTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/Converters/NullableDateTimeConverterTests.cs new file mode 100644 index 00000000..2c0fb17a --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/Converters/NullableDateTimeConverterTests.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using PinguApps.Appwrite.Shared.Converters; + +namespace PinguApps.Appwrite.Shared.Tests.Converters; + +public class NullableDateTimeConverterTests +{ + private readonly JsonSerializerOptions _options; + + public NullableDateTimeConverterTests() + { + _options = new JsonSerializerOptions(); + _options.Converters.Add(new NullableDateTimeConverter()); + } + + [Fact] + public void Read_ValidDateString_ReturnsDateTime() + { + var json = "\"2023-01-01T00:00:00\""; + var result = JsonSerializer.Deserialize(json, _options); + Assert.NotNull(result); + Assert.Equal(new DateTime(2023, 1, 1), result.Value); + } + + [Fact] + public void Read_EmptyString_ReturnsNull() + { + var json = "\"\""; + var result = JsonSerializer.Deserialize(json, _options); + Assert.Null(result); + } + + [Fact] + public void Read_NullToken_ReturnsNull() + { + var json = "null"; + var result = JsonSerializer.Deserialize(json, _options); + Assert.Null(result); + } + + public class NullableDateTimeObject + { + [JsonPropertyName("x")] + [JsonConverter(typeof(NullableDateTimeConverter))] + public DateTime? X { get; set; } + } + + [Fact] + public void Read_NullTokenInObject_ReturnsNull() + { + var json = "{\"x\": null}"; + var result = JsonSerializer.Deserialize(json, _options); + Assert.NotNull(result); + Assert.Null(result.X); + } + + [Fact] + public void Read_InvalidDateString_ThrowsJsonException() + { + var json = "\"invalid-date\""; + Assert.Throws(() => JsonSerializer.Deserialize(json, _options)); + } + + [Fact] + public void Read_UnexpectedTokenType_ThrowsJsonException() + { + var json = "123"; + Assert.Throws(() => JsonSerializer.Deserialize(json, _options)); + } + + [Fact] + public void Write_NonNullDateTime_WritesExpectedString() + { + var dateTime = new DateTime(2023, 1, 1); + var json = JsonSerializer.Serialize(dateTime, _options); + Assert.Equal("\"2023-01-01T00:00:00.0000000\"", json); + } + + [Fact] + public void Write_NullDateTime_WritesNullValue() + { + var json = JsonSerializer.Serialize(null, _options); + Assert.Equal("null", json); + } + + [Fact] + public void Write_NullDateTimeInObject_WritesNullValue() + { + var json = JsonSerializer.Serialize(new NullableDateTimeObject(), _options); + Assert.Equal("{\"x\":null}", json); + } +} diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreateSessionRequestTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreateSessionRequestTests.cs new file mode 100644 index 00000000..6884a1bc --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreateSessionRequestTests.cs @@ -0,0 +1,108 @@ + +using FluentValidation; +using PinguApps.Appwrite.Shared.Requests; + +namespace PinguApps.Appwrite.Shared.Tests.Requests; +public class CreateSessionRequestTests +{ + [Fact] + public void Constructor_InitializesWithExpectedValues() + { + // Arrange & Act + var request = new CreateSessionRequest(); + + // Assert + Assert.NotEmpty(request.UserId); + Assert.Equal(string.Empty, request.Secret); + } + + [Fact] + public void Properties_CanBeSet() + { + var userId = "123456"; + var secret = "test@example.com"; + + // Arrange + var request = new CreateSessionRequest(); + + // Act + request.UserId = userId; + request.Secret = secret; + + // Assert + Assert.Equal(userId, request.UserId); + Assert.Equal(secret, request.Secret); + } + + [Fact] + public void IsValid_WithValidData_ReturnsTrue() + { + // Arrange + var request = new CreateSessionRequest + { + UserId = "123456", + Secret = "654321" + }; + + // Act + var isValid = request.IsValid(); + + // Assert + Assert.True(isValid); + } + + [Theory] + [InlineData("badChar^", "654321")] + [InlineData(".bad", "654321")] + [InlineData("_bad", "654321")] + [InlineData("-bad", "654321")] + [InlineData("", "654321")] + [InlineData("1234567890123456789012345678901234567", "654321")] + [InlineData("123456", "")] + public void IsValid_WithInvalidData_ReturnsFalse(string userId, string secret) + { + // Arrange + var request = new CreateSessionRequest + { + UserId = userId, + Secret = secret + }; + + // Act + var isValid = request.IsValid(); + + // Assert + Assert.False(isValid); + } + + [Fact] + public void Validate_WithThrowOnFailuresTrue_ThrowsValidationExceptionOnFailure() + { + // Arrange + var request = new CreateSessionRequest + { + UserId = ".badChar^", + Secret = "" + }; + + // Assert + Assert.Throws(() => request.Validate(true)); + } + + [Fact] + public void Validate_WithThrowOnFailuresFalse_ReturnsInvalidResultOnFailure() + { + // Arrange + var request = new CreateSessionRequest + { + UserId = ".badChar^", + Secret = "" + }; + + // Act + var result = request.Validate(false); + + // Assert + Assert.False(result.IsValid); + } +} diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Responses/SessionTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/Responses/SessionTests.cs new file mode 100644 index 00000000..6f1fd979 --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/Responses/SessionTests.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using PinguApps.Appwrite.Shared.Responses; + +namespace PinguApps.Appwrite.Shared.Tests.Responses; +public class SessionTests +{ + [Fact] + public void Session_Constructor_AssignsPropertiesCorrectly() + { + // Arrange + var id = "5e5ea5c16897e"; + var createdAt = DateTime.UtcNow; + var updatedAt = DateTime.UtcNow; + var userId = "5e5bb8c16897e"; + var expiresAt = DateTime.UtcNow; + var provider = "email"; + var providerUserId = "user@example.com"; + var providerAccessToken = "MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3"; + var providerAccessTokenExpiry = DateTime.UtcNow; + var ProviderRefreshToken = "MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3"; + var ip = "127.0.0.1"; + var osCode = "Mac"; + var osName = "Mac"; + var osVersion = "Mac"; + var clientType = "browser"; + var clientCode = "CM"; + var clientName = "Chrome Mobile iOS"; + var clientVersion = "84.0"; + var clientEngine = "WebKit"; + var clientEngineVersion = "605.1.15"; + var deviceName = "smartphone"; + var deviceBrand = "Google"; + var deviceModel = "Nexus 5"; + var countryCode = "US"; + var countryName = "United States"; + var current = true; + var factors = new List { "email" }; + var secret = "5e5bb8c16897e"; + var mfaUpdatedAt = DateTime.UtcNow; + + // Act + var session = new Session(id, createdAt, updatedAt, userId, expiresAt, provider, providerUserId, providerAccessToken, + providerAccessTokenExpiry, ProviderRefreshToken, ip, osCode, osName, osVersion, clientType, clientCode, clientName, + clientVersion, clientEngine, clientEngineVersion, deviceName, deviceBrand, deviceModel, countryCode, countryName, + current, factors, secret, mfaUpdatedAt); + + // Assert + Assert.Equal(id, session.Id); + } + + [Fact] + public void Session_CanBeDeserialized_FromJson() + { + // Act + var session = JsonSerializer.Deserialize(Constants.SessionResponse, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + // Assert + Assert.NotNull(session); + Assert.Equal("5e5ea5c16897e", session.Id); + Assert.Equal(DateTime.Parse("2020-10-15T06:38:00.000+00:00").ToUniversalTime(), session.CreatedAt.ToUniversalTime()); + Assert.Equal(DateTime.Parse("2020-10-15T06:38:00.000+00:00").ToUniversalTime(), session.UpdatedAt.ToUniversalTime()); + Assert.Equal("5e5bb8c16897e", session.UserId); + Assert.Equal(DateTime.Parse("2020-10-15T06:38:00.000+00:00").ToUniversalTime(), session.ExpiresAt.ToUniversalTime()); + Assert.Equal("email", session.Provider); + Assert.Equal("user@example.com", session.ProviderUserId); + Assert.Equal("MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", session.ProviderAccessToken); + Assert.NotNull(session.ProviderAccessTokenExpiry); + Assert.Equal(DateTime.Parse("2020-10-15T06:38:00.000+00:00").ToUniversalTime(), session.ProviderAccessTokenExpiry.Value.ToUniversalTime()); + Assert.Equal("MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", session.ProviderRefreshToken); + Assert.Equal("127.0.0.1", session.Ip); + Assert.Equal("Mac", session.OsCode); + Assert.Equal("Mac", session.OsName); + Assert.Equal("Mac", session.OsVersion); + Assert.Equal("browser", session.ClientType); + Assert.Equal("CM", session.ClientCode); + Assert.Equal("Chrome Mobile iOS", session.ClientName); + Assert.Equal("84.0", session.ClientVersion); + Assert.Equal("WebKit", session.ClientEngine); + Assert.Equal("605.1.15", session.ClientEngineVersion); + Assert.Equal("smartphone", session.DeviceName); + Assert.Equal("Google", session.DeviceBrand); + Assert.Equal("Nexus 5", session.DeviceModel); + Assert.Equal("US", session.CountryCode); + Assert.Equal("United States", session.CountryName); + Assert.True(session.Current); + Assert.Single(session.Factors); + Assert.Equal("email", session.Factors[0]); + Assert.Equal("5e5bb8c16897e", session.Secret); + Assert.NotNull(session.MfaUpdatedAt); + Assert.Equal(DateTime.Parse("2020-10-15T06:38:00.000+00:00").ToUniversalTime(), session.MfaUpdatedAt.Value.ToUniversalTime()); + } +}