diff --git a/src/Passwordless/Helpers/PasswordlessSerializerContext.cs b/src/Passwordless/Helpers/PasswordlessSerializerContext.cs index 774ff7a..504fd59 100644 --- a/src/Passwordless/Helpers/PasswordlessSerializerContext.cs +++ b/src/Passwordless/Helpers/PasswordlessSerializerContext.cs @@ -24,6 +24,8 @@ namespace Passwordless.Helpers; [JsonSerializable(typeof(PasswordlessProblemDetails))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(GetEventLogRequest))] +[JsonSerializable(typeof(GetEventLogResponse))] internal partial class PasswordlessSerializerContext : JsonSerializerContext { diff --git a/src/Passwordless/IPasswordlessClient.cs b/src/Passwordless/IPasswordlessClient.cs index 9173e92..33b7923 100644 --- a/src/Passwordless/IPasswordlessClient.cs +++ b/src/Passwordless/IPasswordlessClient.cs @@ -136,4 +136,12 @@ Task DeleteCredentialAsync( byte[] id, CancellationToken cancellationToken = default ); + + /// + /// Gets a list of events for the application. + /// + /// + /// + /// + Task GetEventLogAsync(GetEventLogRequest request, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/Passwordless/Models/GetEventLogRequest.cs b/src/Passwordless/Models/GetEventLogRequest.cs new file mode 100644 index 0000000..6385011 --- /dev/null +++ b/src/Passwordless/Models/GetEventLogRequest.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace Passwordless.Models; + +/// +/// Request for getting the event logs for an application. +/// +public class GetEventLogRequest +{ + /// + /// Page number for retrieving event log records. + /// + public int PageNumber { get; set; } + + /// + /// This is the max number of results that will be returned. Must be between 1-1000. + /// + [Range(1, 1000)] + public int? NumberOfResults { get; set; } +} \ No newline at end of file diff --git a/src/Passwordless/Models/GetEventLogResponse.cs b/src/Passwordless/Models/GetEventLogResponse.cs new file mode 100644 index 0000000..716e7d2 --- /dev/null +++ b/src/Passwordless/Models/GetEventLogResponse.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace Passwordless.Models; + +/// +/// Response from GetEventLog. Contains list of events for the application. +/// +public class GetEventLogResponse +{ + /// + /// Name of application the events correspond to. + /// + public string TenantId { get; set; } = string.Empty; + /// + /// List of events for the application based on the request pagination parameters. This will always be sorted by PerformedAt in descending order. + /// + public IEnumerable Events { get; set; } = new List(); + /// + /// Total number of events for the application. + /// + public int TotalEventCount { get; set; } +} + +/// +/// An event that occured using Passwordless library. +/// +public class ApplicationEvent +{ + public Guid Id { get; set; } + /// + /// When the record was performed. This will be in UTC. + /// + public DateTime PerformedAt { get; set; } + + /// + /// The type of event + /// + public string EventType { get; set; } = string.Empty; + + /// + /// Description of the event + /// + public string Message { get; set; } = string.Empty; + + /// + /// Severity of the event + /// + public string Severity { get; set; } = string.Empty; + + /// + /// The target of the event. Can be in reference to a user or the application. + /// + public string Subject { get; set; } = string.Empty; + + /// + /// Last 4 characters of the api key (public/secret) used to perform the event. + /// + public string ApiKeyId { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/Passwordless/PasswordlessClient.cs b/src/Passwordless/PasswordlessClient.cs index f89bb34..3e9178c 100644 --- a/src/Passwordless/PasswordlessClient.cs +++ b/src/Passwordless/PasswordlessClient.cs @@ -8,7 +8,6 @@ using System.Threading.Tasks; using Passwordless.Helpers; using Passwordless.Models; -using JsonContext = Passwordless.Helpers.PasswordlessSerializerContext; namespace Passwordless; @@ -116,6 +115,12 @@ public async Task VerifyTokenAsync( ))!; } + /// + public async Task GetEventLogAsync(GetEventLogRequest request, CancellationToken cancellationToken = default) => + (await _http.GetFromJsonAsync($"events?pageNumber={request.PageNumber}&numberOfResults={request.NumberOfResults}", + PasswordlessSerializerContext.Default.GetEventLogResponse, + cancellationToken)) ?? new GetEventLogResponse(); + /// public async Task GetUsersCountAsync(CancellationToken cancellationToken = default) => (await _http.GetFromJsonAsync( diff --git a/tests/Passwordless.Tests.Infra/TestApi.cs b/tests/Passwordless.Tests.Infra/TestApi.cs index c7d9290..cd31c0e 100644 --- a/tests/Passwordless.Tests.Infra/TestApi.cs +++ b/tests/Passwordless.Tests.Infra/TestApi.cs @@ -28,6 +28,8 @@ public class TestApi : IAsyncDisposable private string PublicApiUrl => $"http://{_apiContainer.Hostname}:{_apiContainer.GetMappedPublicPort(ApiPort)}"; + public static string GetAppName() => $"app{Guid.NewGuid():N}"; + public TestApi() { _apiContainer = new ContainerBuilder() @@ -82,11 +84,13 @@ public async Task InitializeAsync() } } - public async Task CreateAppAsync() + public Task CreateAppAsync() => CreateAppAsync($"app{Guid.NewGuid():N}"); + + public async Task CreateAppAsync(string appName) { using var request = new HttpRequestMessage( HttpMethod.Post, - $"{PublicApiUrl}/admin/apps/app{Guid.NewGuid():N}/create" + $"{PublicApiUrl}/admin/apps/{appName}/create" ); request.Content = new StringContent( @@ -137,6 +141,49 @@ public async Task CreateClientAsync() }).BuildServiceProvider().GetRequiredService(); } + public async Task CreateClientAsync(string applicationName) + { + var options = await CreateAppAsync(applicationName); + + // Initialize using a service container to cover more code paths + return new ServiceCollection().AddPasswordlessSdk(o => + { + o.ApiUrl = options.ApiUrl; + o.ApiKey = options.ApiKey; + o.ApiSecret = options.ApiSecret; + }).BuildServiceProvider().GetRequiredService(); + } + + public async Task EnableEventLogsAsync(string applicationName) + { + using var request = new HttpRequestMessage( + HttpMethod.Post, + $"{PublicApiUrl}/admin/apps/{applicationName}/features" + ); + request.Content = new StringContent( + // lang=json + """ + { + "EventLoggingIsEnabled": true, + "EventLoggingRetentionPeriod": 7, + "MaxUsers": null + } + """, + Encoding.UTF8, + "application/json"); + + using var response = await _http.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Failed to enable event logging. " + + $"Status code: {(int)response.StatusCode}. " + + $"Response body: {await response.Content.ReadAsStringAsync()}." + ); + } + } + public string GetLogs() { var apiContainerStdOutText = Encoding.UTF8.GetString( diff --git a/tests/Passwordless.Tests/ApplicationEventLogsTests.cs b/tests/Passwordless.Tests/ApplicationEventLogsTests.cs new file mode 100644 index 0000000..2ff7c41 --- /dev/null +++ b/tests/Passwordless.Tests/ApplicationEventLogsTests.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using FluentAssertions; +using Passwordless.Models; +using Passwordless.Tests.Infra; +using Xunit; +using Xunit.Abstractions; + +namespace Passwordless.Tests; + +public class ApplicationEventLogsTests(TestApiFixture api, ITestOutputHelper testOutput) : ApiTestBase(api, testOutput) +{ + [Fact] + public async Task I_can_view_application_event_logs_when_event_logs_are_enabled() + { + // Arrange + var applicationName = TestApi.GetAppName(); + var passwordless = await Api.CreateClientAsync(applicationName); + await Api.EnableEventLogsAsync(applicationName); + + // Act + var response = await passwordless.GetEventLogAsync(new GetEventLogRequest { PageNumber = 1, NumberOfResults = 100 }); + + // Assert + response.Should().NotBeNull(); + response.TenantId.Should().Be(applicationName); + } +} \ No newline at end of file