diff --git a/src/Exceptionless.Web/Bootstrapper.cs b/src/Exceptionless.Web/Bootstrapper.cs index e330addfdf..7eb404ae13 100644 --- a/src/Exceptionless.Web/Bootstrapper.cs +++ b/src/Exceptionless.Web/Bootstrapper.cs @@ -2,6 +2,7 @@ using Exceptionless.Core.Jobs.WorkItemHandlers; using Exceptionless.Web.Hubs; using Exceptionless.Web.Mapping; +using Exceptionless.Web.Security; using Foundatio.Extensions.Hosting.Startup; using Foundatio.Jobs; using Foundatio.Messaging; @@ -16,6 +17,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); Core.Bootstrapper.RegisterServices(services, appOptions); Insulation.Bootstrapper.RegisterServices(services, appOptions, appOptions.RunJobsInProcess); diff --git a/src/Exceptionless.Web/Controllers/AuthController.cs b/src/Exceptionless.Web/Controllers/AuthController.cs index c86b630087..fd9dd4160f 100644 --- a/src/Exceptionless.Web/Controllers/AuthController.cs +++ b/src/Exceptionless.Web/Controllers/AuthController.cs @@ -11,16 +11,13 @@ using Exceptionless.DateTimeExtensions; using Exceptionless.Web.Extensions; using Exceptionless.Web.Models; +using Exceptionless.Web.Security; using Foundatio.Caching; using Foundatio.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.IdentityModel.Tokens; -using OAuth2.Client; -using OAuth2.Client.Impl; -using OAuth2.Configuration; -using OAuth2.Infrastructure; using OAuth2.Models; namespace Exceptionless.Web.Controllers; @@ -36,6 +33,7 @@ public class AuthController : ExceptionlessApiController private readonly IUserRepository _userRepository; private readonly ITokenRepository _tokenRepository; private readonly IOAuthTokenRepository _oauthTokenRepository; + private readonly IOAuthProviderClient _oauthProviderClient; private readonly ScopedCacheClient _cache; private readonly IMailer _mailer; private readonly ILogger _logger; @@ -44,7 +42,7 @@ public class AuthController : ExceptionlessApiController private static readonly TimeSpan IntercomJwtLifetime = TimeSpan.FromMinutes(60); public AuthController(AuthOptions authOptions, IntercomOptions intercomOptions, IOrganizationRepository organizationRepository, IUserRepository userRepository, - ITokenRepository tokenRepository, IOAuthTokenRepository oauthTokenRepository, ICacheClient cacheClient, IMailer mailer, IDomainLoginProvider domainLoginProvider, + ITokenRepository tokenRepository, IOAuthTokenRepository oauthTokenRepository, IOAuthProviderClient oauthProviderClient, ICacheClient cacheClient, IMailer mailer, IDomainLoginProvider domainLoginProvider, TimeProvider timeProvider, ILogger logger) : base(timeProvider) { _authOptions = authOptions; @@ -54,6 +52,7 @@ public AuthController(AuthOptions authOptions, IntercomOptions intercomOptions, _userRepository = userRepository; _tokenRepository = tokenRepository; _oauthTokenRepository = oauthTokenRepository; + _oauthProviderClient = oauthProviderClient; _cache = new ScopedCacheClient(cacheClient, "Auth"); _mailer = mailer; _logger = logger; @@ -335,11 +334,7 @@ public Task> GitHubAsync(ExternalAuthInfo value) return ExternalLoginAsync(value, _authOptions.GitHubId, _authOptions.GitHubSecret, - (f, c) => - { - c.Scope = "user:email"; - return new GitHubClient(f, c); - } + _oauthProviderClient.GetGitHubUserInfoAsync ); } @@ -357,11 +352,7 @@ public Task> GoogleAsync(ExternalAuthInfo value) return ExternalLoginAsync(value, _authOptions.GoogleId, _authOptions.GoogleSecret, - (f, c) => - { - c.Scope = "profile email"; - return new GoogleClient(f, c); - } + _oauthProviderClient.GetGoogleUserInfoAsync ); } @@ -379,11 +370,7 @@ public Task> FacebookAsync(ExternalAuthInfo value) return ExternalLoginAsync(value, _authOptions.FacebookId, _authOptions.FacebookSecret, - (f, c) => - { - c.Scope = "email"; - return new FacebookClient(f, c); - } + _oauthProviderClient.GetFacebookUserInfoAsync ); } @@ -401,11 +388,7 @@ public Task> LiveAsync(ExternalAuthInfo value) return ExternalLoginAsync(value, _authOptions.MicrosoftId, _authOptions.MicrosoftSecret, - (f, c) => - { - c.Scope = "wl.emails"; - return new WindowsLiveClient(f, c); - } + _oauthProviderClient.GetMicrosoftUserInfoAsync ); } @@ -672,23 +655,16 @@ private async Task AddGlobalAdminRoleIfFirstUserAsync(User user) _isFirstUserChecked = true; } - private async Task> ExternalLoginAsync(ExternalAuthInfo authInfo, string? appId, string? appSecret, Func createClient) where TClient : OAuth2Client + private async Task> ExternalLoginAsync(ExternalAuthInfo authInfo, string? appId, string? appSecret, Func> getUserInfoAsync) { using var _ = _logger.BeginScope(new ExceptionlessState().Tag("External Login").SetHttpContext(HttpContext)); if (String.IsNullOrEmpty(appId) || String.IsNullOrEmpty(appSecret)) throw new ConfigurationErrorsException("Missing Configuration for OAuth provider"); - var client = createClient(new RequestFactory(), new OAuth2.Configuration.ClientConfiguration - { - ClientId = appId, - ClientSecret = appSecret, - RedirectUri = authInfo.RedirectUri - }); - UserInfo userInfo; try { - userInfo = await client.GetUserInfoAsync(authInfo.Code, authInfo.RedirectUri); + userInfo = await getUserInfoAsync(authInfo, appId, appSecret); } catch (Exception ex) { diff --git a/src/Exceptionless.Web/Controllers/ProjectController.cs b/src/Exceptionless.Web/Controllers/ProjectController.cs index 037d7b99e8..6bf7f4c0f3 100644 --- a/src/Exceptionless.Web/Controllers/ProjectController.cs +++ b/src/Exceptionless.Web/Controllers/ProjectController.cs @@ -12,6 +12,7 @@ using Exceptionless.Web.Extensions; using Exceptionless.Web.Mapping; using Exceptionless.Web.Models; +using Exceptionless.Web.Security; using Exceptionless.Web.Utility; using Foundatio.Jobs; using Foundatio.Queues; @@ -35,6 +36,7 @@ public class ProjectController : RepositoryApiController _workItemQueue; private readonly BillingManager _billingManager; private readonly SlackService _slackService; + private readonly IOAuthProviderClient _oauthProviderClient; private readonly ITextSerializer _serializer; private readonly AppOptions _options; private readonly UsageService _usageService; @@ -49,6 +51,7 @@ public ProjectController( IQueue workItemQueue, BillingManager billingManager, SlackService slackService, + IOAuthProviderClient oauthProviderClient, SampleDataService sampleDataService, ApiMapper mapper, IAppQueryValidator validator, @@ -67,6 +70,7 @@ ILoggerFactory loggerFactory _workItemQueue = workItemQueue; _billingManager = billingManager; _slackService = slackService; + _oauthProviderClient = oauthProviderClient; _serializer = serializer; _sampleDataService = sampleDataService; _options = options; @@ -679,7 +683,7 @@ public async Task AddSlackAsync(string id, string code) SlackToken? token; try { - token = await _slackService.GetAccessTokenAsync(code); + token = await _oauthProviderClient.GetSlackAccessTokenAsync(code); } catch (Exception ex) { diff --git a/src/Exceptionless.Web/Security/OAuthProviderClient.cs b/src/Exceptionless.Web/Security/OAuthProviderClient.cs new file mode 100644 index 0000000000..c7427390a2 --- /dev/null +++ b/src/Exceptionless.Web/Security/OAuthProviderClient.cs @@ -0,0 +1,81 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Services; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Models; +using OAuth2.Client; +using OAuth2.Client.Impl; +using OAuth2.Configuration; +using OAuth2.Infrastructure; +using OAuth2.Models; + +namespace Exceptionless.Web.Security; + +public interface IOAuthProviderClient +{ + Task GetFacebookUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret); + Task GetGitHubUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret); + Task GetGoogleUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret); + Task GetMicrosoftUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret); + Task GetSlackAccessTokenAsync(string code); +} + +public sealed class OAuthProviderClient(SlackService slackService) : IOAuthProviderClient +{ + public Task GetFacebookUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) + { + return GetUserInfoAsync(authInfo, appId, appSecret, (factory, configuration) => + { + configuration.Scope = "email"; + return new FacebookClient(factory, configuration); + }); + } + + public Task GetGitHubUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) + { + return GetUserInfoAsync(authInfo, appId, appSecret, (factory, configuration) => + { + configuration.Scope = "user:email"; + return new GitHubClient(factory, configuration); + }); + } + + public Task GetGoogleUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) + { + return GetUserInfoAsync(authInfo, appId, appSecret, (factory, configuration) => + { + configuration.Scope = "profile email"; + return new GoogleClient(factory, configuration); + }); + } + + public Task GetMicrosoftUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) + { + return GetUserInfoAsync(authInfo, appId, appSecret, (factory, configuration) => + { + configuration.Scope = "wl.emails"; + return new WindowsLiveClient(factory, configuration); + }); + } + + public Task GetSlackAccessTokenAsync(string code) + { + return slackService.GetAccessTokenAsync(code); + } + + private static Task GetUserInfoAsync( + ExternalAuthInfo authInfo, + string appId, + string appSecret, + Func createClient + ) where TClient : OAuth2Client + { + var client = createClient(new RequestFactory(), new OAuth2.Configuration.ClientConfiguration + { + ClientId = appId, + ClientSecret = appSecret, + RedirectUri = authInfo.RedirectUri + }); + + return client.GetUserInfoAsync(authInfo.Code, authInfo.RedirectUri); + } +} diff --git a/tests/Exceptionless.Tests/Controllers/AdminControllerTests.cs b/tests/Exceptionless.Tests/Controllers/AdminControllerTests.cs index e915b521e9..6538b46e42 100644 --- a/tests/Exceptionless.Tests/Controllers/AdminControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/AdminControllerTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization; using Exceptionless.Core.Billing; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; @@ -46,6 +47,24 @@ protected override async Task ResetDataAsync() await service.CreateDataAsync(); } + [Fact] + public async Task RequeueAsync_WithoutPath_ReturnsEmptyEnqueuedCount() + { + // Arrange + const int expectedEnqueuedCount = 0; + + // Act + var response = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "requeue") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(response); + Assert.Equal(expectedEnqueuedCount, response.Enqueued); + } + [Fact] public async Task RunJobAsync_WhenFixStackStatsWithExplicitUtcWindow_ShouldRepairStatsEndToEnd() { @@ -631,6 +650,72 @@ public Task EchoRequest_WithoutAuth_ReturnsUnauthorized() .StatusCodeShouldBeUnauthorized()); } + [Fact] + public async Task GenerateSampleEventsAsync_WithInvalidDaysBack_ReturnsValidationError() + { + // Arrange + const int eventCount = 25; + const int daysBack = 0; + + // Act + var response = await SendRequestAsync(r => r + .AsGlobalAdminUser() + .Post() + .AppendPaths("admin", "generate-sample-events") + .QueryString("eventCount", eventCount) + .QueryString("daysBack", daysBack) + .StatusCodeShouldBeUnprocessableEntity()); + + // Assert + Assert.NotNull(response); + var stats = await _workItemQueue.GetQueueStatsAsync(); + Assert.Equal(0, stats.Enqueued); + } + + [Fact] + public async Task GenerateSampleEventsAsync_WithInvalidEventCount_ReturnsValidationError() + { + // Arrange + const int eventCount = 0; + const int daysBack = 7; + + // Act + var response = await SendRequestAsync(r => r + .AsGlobalAdminUser() + .Post() + .AppendPaths("admin", "generate-sample-events") + .QueryString("eventCount", eventCount) + .QueryString("daysBack", daysBack) + .StatusCodeShouldBeUnprocessableEntity()); + + // Assert + Assert.NotNull(response); + var stats = await _workItemQueue.GetQueueStatsAsync(); + Assert.Equal(0, stats.Enqueued); + } + + [Fact] + public async Task GenerateSampleEventsAsync_WithValidArguments_QueuesGenerateSampleEventsWorkItem() + { + // Arrange + const int eventCount = 25; + const int daysBack = 14; + + // Act + var response = await SendRequestAsync(r => r + .AsGlobalAdminUser() + .Post() + .AppendPaths("admin", "generate-sample-events") + .QueryString("eventCount", eventCount) + .QueryString("daysBack", daysBack) + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(response); + var stats = await _workItemQueue.GetQueueStatsAsync(); + Assert.Equal(1, stats.Enqueued); + } + [Fact] public async Task GetAssemblies_AsGlobalAdmin_ReturnsAssemblyList() { @@ -804,4 +889,6 @@ public Task SetBonusAsync_AsNonAdmin_ReturnsForbidden() .QueryString("bonusEvents", 1000) .StatusCodeShouldBeForbidden()); } + + private sealed record RequeueResult([property: JsonPropertyName("enqueued")] int Enqueued); } diff --git a/tests/Exceptionless.Tests/Controllers/AuthControllerTests.cs b/tests/Exceptionless.Tests/Controllers/AuthControllerTests.cs index 8f174ef758..96c8bec835 100644 --- a/tests/Exceptionless.Tests/Controllers/AuthControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/AuthControllerTests.cs @@ -30,12 +30,12 @@ public class AuthControllerTests : IntegrationTestsBase private readonly IOrganizationRepository _organizationRepository; private readonly ITokenRepository _tokenRepository; private readonly IOAuthTokenRepository _oauthTokenRepository; + private readonly AuthOptionsState _originalAuthOptions; public AuthControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { _authOptions = GetService(); - _authOptions.EnableAccountCreation = true; - _authOptions.EnableActiveDirectoryAuth = false; + _originalAuthOptions = AuthOptionsState.Capture(_authOptions); _intercomOptions = GetService(); _organizationRepository = GetService(); @@ -47,10 +47,31 @@ public AuthControllerTests(ITestOutputHelper output, AppWebHostFactory factory) protected override async Task ResetDataAsync() { await base.ResetDataAsync(); + ConfigureAuthOptions(); var service = GetService(); await service.CreateDataAsync(); } + public override ValueTask DisposeAsync() + { + _originalAuthOptions.Apply(_authOptions); + return base.DisposeAsync(); + } + + private void ConfigureAuthOptions() + { + _authOptions.EnableAccountCreation = true; + _authOptions.EnableActiveDirectoryAuth = false; + _authOptions.FacebookId = "facebook-client-id"; + _authOptions.FacebookSecret = "facebook-client-secret"; + _authOptions.GitHubId = "github-client-id"; + _authOptions.GitHubSecret = "github-client-secret"; + _authOptions.GoogleId = "google-client-id"; + _authOptions.GoogleSecret = "google-client-secret"; + _authOptions.MicrosoftId = "microsoft-client-id"; + _authOptions.MicrosoftSecret = "microsoft-client-secret"; + } + [Fact] public async Task CannotSignupWithoutPassword() { @@ -486,6 +507,58 @@ await SendRequestAsync(r => r ); } + [Fact] + public async Task FacebookAsync_WithConfiguredProvider_ReturnsToken() + { + // Arrange + const string code = "facebook-user"; + + // Act + var result = await SendExternalLoginAsync("facebook", code); + + // Assert + await AssertExternalLoginAsync(result, "facebook", code); + } + + [Fact] + public async Task GitHubAsync_WithConfiguredProvider_ReturnsToken() + { + // Arrange + const string code = "github-user"; + + // Act + var result = await SendExternalLoginAsync("github", code); + + // Assert + await AssertExternalLoginAsync(result, "github", code); + } + + [Fact] + public async Task GoogleAsync_WithConfiguredProvider_ReturnsToken() + { + // Arrange + const string code = "google-user"; + + // Act + var result = await SendExternalLoginAsync("google", code); + + // Assert + await AssertExternalLoginAsync(result, "google", code); + } + + [Fact] + public async Task LiveAsync_WithConfiguredProvider_ReturnsToken() + { + // Arrange + const string code = "live-user"; + + // Act + var result = await SendExternalLoginAsync("live", code); + + // Assert + await AssertExternalLoginAsync(result, "windowslive", code); + } + [Fact] public async Task LoginValidAsync() { @@ -521,6 +594,50 @@ public async Task LoginValidAsync() Assert.False(String.IsNullOrEmpty(result.Token)); } + [Fact] + public async Task RemoveExternalLoginAsync_WithLinkedAccount_RemovesAccount() + { + // Arrange + const string providerName = "github"; + const string providerUserId = "github-remove-user"; + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + user.AddOAuthAccount(providerName, providerUserId, user.EmailAddress); + await _userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache()); + + // Act + var result = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("auth", "unlink", providerName) + .Content(new ValueFromBody(providerUserId)) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(result); + Assert.False(String.IsNullOrEmpty(result.Token)); + var updatedUser = await _userRepository.GetByIdAsync(user.Id); + Assert.NotNull(updatedUser); + Assert.DoesNotContain(updatedUser.OAuthAccounts, account => account.Provider == providerName && account.ProviderUserId == providerUserId); + } + + [Fact] + public Task RemoveExternalLoginAsync_WithoutProviderUserId_ReturnsBadRequest() + { + // Arrange + var providerUserId = new ValueFromBody(String.Empty); + + // Act & Assert + return SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("auth", "unlink", "github") + .Content(providerUserId) + .StatusCodeShouldBeBadRequest() + ); + } + [Fact] public async Task LoginInvalidPasswordAsync() { @@ -1268,4 +1385,76 @@ await SendRequestAsync(r => r Assert.False(token.IsDisabled); Assert.False(token.IsSuspended); } + + private async Task AssertExternalLoginAsync(TokenResult? result, string providerName, string providerUserId) + { + Assert.NotNull(result); + Assert.False(String.IsNullOrEmpty(result.Token)); + + var user = await _userRepository.GetByEmailAddressAsync(TestOAuthProviderClient.GetEmailAddress(providerUserId)); + Assert.NotNull(user); + Assert.True(user.IsEmailAddressVerified); + var account = Assert.Single(user.OAuthAccounts); + Assert.Equal(providerName, account.Provider); + Assert.Equal(providerUserId, account.ProviderUserId); + Assert.Equal(user.EmailAddress, account.Username); + } + + private Task SendExternalLoginAsync(string providerPath, string code) + { + return SendRequestAsAsync(r => r + .Post() + .AppendPaths("auth", providerPath) + .Content(new ExternalAuthInfo + { + ClientId = "client-id", + Code = code, + RedirectUri = "http://localhost/callback" + }) + .StatusCodeShouldBeOk() + ); + } + + private sealed record AuthOptionsState( + bool EnableAccountCreation, + bool EnableActiveDirectoryAuth, + string? FacebookId, + string? FacebookSecret, + string? GitHubId, + string? GitHubSecret, + string? GoogleId, + string? GoogleSecret, + string? MicrosoftId, + string? MicrosoftSecret + ) + { + public static AuthOptionsState Capture(AuthOptions options) + { + return new AuthOptionsState( + options.EnableAccountCreation, + options.EnableActiveDirectoryAuth, + options.FacebookId, + options.FacebookSecret, + options.GitHubId, + options.GitHubSecret, + options.GoogleId, + options.GoogleSecret, + options.MicrosoftId, + options.MicrosoftSecret); + } + + public void Apply(AuthOptions options) + { + options.EnableAccountCreation = EnableAccountCreation; + options.EnableActiveDirectoryAuth = EnableActiveDirectoryAuth; + options.FacebookId = FacebookId; + options.FacebookSecret = FacebookSecret; + options.GitHubId = GitHubId; + options.GitHubSecret = GitHubSecret; + options.GoogleId = GoogleId; + options.GoogleSecret = GoogleSecret; + options.MicrosoftId = MicrosoftId; + options.MicrosoftSecret = MicrosoftSecret; + } + } } diff --git a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs index 8d4a716e83..dca6b4cfaa 100644 --- a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs @@ -73,6 +73,181 @@ protected override async Task ResetDataAsync() await service.CreateDataAsync(); } + [Fact] + public async Task GetByOrganizationAsync_WithExistingEvents_ReturnsOrganizationEvents() + { + // Arrange + await CreateDataAsync(d => d.Event().TestProject().Message("organization route")); + await RefreshDataAsync(); + + // Act + var events = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "events") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(events); + Assert.Contains(events, e => String.Equals(e.Message, "organization route", StringComparison.Ordinal)); + } + + [Fact] + public async Task GetByReferenceIdAsync_WithExistingReference_ReturnsMatchingEvents() + { + // Arrange + string referenceId = Guid.NewGuid().ToString("N"); + await CreateDataAsync(d => d.Event().TestProject().ReferenceId(referenceId).Message("reference route")); + await RefreshDataAsync(); + + // Act + var events = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPaths("events", "by-ref", referenceId) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(events); + var ev = Assert.Single(events); + Assert.Equal(referenceId, ev.ReferenceId); + } + + [Fact] + public async Task GetCountByOrganizationAsync_WithExistingEvents_ReturnsOrganizationCount() + { + // Arrange + await CreateDataAsync(d => d.Event().TestProject().Message("organization count route")); + await RefreshDataAsync(); + + // Act + var count = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "events", "count") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(count); + Assert.True(count.Total > 0); + } + + [Fact] + public async Task GetSessionByOrganizationAsync_WithSessionEvents_ReturnsOrganizationSessions() + { + // Arrange + string sessionId = Guid.NewGuid().ToString("N"); + await CreateDataAsync(d => d.Event().TestProject().Type(Event.KnownTypes.Session).SessionId(sessionId)); + await RefreshDataAsync(); + + // Act + var events = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "events", "sessions") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(events); + Assert.Contains(events, e => String.Equals(e.GetSessionId(), sessionId, StringComparison.Ordinal)); + } + + [Fact] + public async Task GetSessionsAsync_WithSessionEvents_ReturnsSessions() + { + // Arrange + string sessionId = Guid.NewGuid().ToString("N"); + await CreateDataAsync(d => d.Event().TestProject().Type(Event.KnownTypes.Session).SessionId(sessionId)); + await RefreshDataAsync(); + + // Act + var events = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPaths("events", "sessions") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(events); + Assert.Contains(events, e => String.Equals(e.GetSessionId(), sessionId, StringComparison.Ordinal)); + } + + [Fact] + public async Task GetSubmitEventV1Async_WithQueryParameters_EnqueuesEvent() + { + // Arrange + string referenceId = Guid.NewGuid().ToString("N"); + + // Act + await SendRequestAsync(r => AppendApiV1Path( + r.AsTestOrganizationClientUser(), + "events", + "submit", + Event.KnownTypes.Log) + .QueryString("message", "legacy get submit route") + .QueryString("reference", referenceId) + .StatusCodeShouldBeOk() + ); + + // Assert + var stats = await _eventQueue.GetQueueStatsAsync(); + Assert.Equal(1, stats.Enqueued); + } + + [Fact] + public async Task LegacyPostAsync_WithTextPayload_EnqueuesEvent() + { + // Arrange + const string payload = "legacy error route"; + + // Act + await SendRequestAsync(r => AppendApiV1Path( + r.Post().AsTestOrganizationClientUser(), + "error") + .Content(payload, "text/plain") + .StatusCodeShouldBeAccepted() + ); + + // Assert + var stats = await _eventQueue.GetQueueStatsAsync(); + Assert.Equal(1, stats.Enqueued); + } + + [Fact] + public async Task PostV1Async_WithTextPayload_EnqueuesEvent() + { + // Arrange + const string payload = "legacy post route"; + + // Act + await SendRequestAsync(r => AppendApiV1Path( + r.Post().AsTestOrganizationClientUser(), + "events") + .Content(payload, "text/plain") + .StatusCodeShouldBeAccepted() + ); + + // Assert + var stats = await _eventQueue.GetQueueStatsAsync(); + Assert.Equal(1, stats.Enqueued); + } + + [Fact] + public Task RecordHeartbeatAsync_WithSessionId_ReturnsOk() + { + // Arrange + string sessionId = Guid.NewGuid().ToString("N"); + + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationClientUser() + .AppendPaths("events", "session", "heartbeat") + .QueryString("id", sessionId) + .QueryString("close", "true") + .StatusCodeShouldBeOk() + ); + } + [Fact] public async Task PostEvent_WithValidPayload_EnqueuesAndProcessesEventAsync() { @@ -2095,11 +2270,10 @@ await SendRequestAsync(r => r await RefreshDataAsync(); // Act — v1 clients sent PascalCase "UserEmail" / "UserDescription" - await SendRequestAsync(r => r - .Patch() - .BaseUri(_server.BaseAddress) - .AsTestOrganizationClientUser() - .AppendPaths("api", "v1", "error", referenceId) + await SendRequestAsync(r => AppendApiV1Path( + r.Patch().AsTestOrganizationClientUser(), + "error", + referenceId) .Content(""" { "UserEmail": "legacy@exceptionless.test", diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index 68fcdd37c9..6e99232bcc 100644 --- a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs @@ -82,6 +82,181 @@ protected override async Task ResetDataAsync() await service.CreateDataAsync(); } + [Fact] + public async Task DeleteDataAsync_WithExistingKey_RemovesDataKey() + { + // Arrange + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "data", "ApiSurfaceKey") + .Content(new ValueFromBody("ApiSurfaceValue")) + .StatusCodeShouldBeOk() + ); + + // Act + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Delete() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "data", "ApiSurfaceKey") + .StatusCodeShouldBeOk() + ); + + // Assert + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + Assert.False(organization.Data?.ContainsKey("ApiSurfaceKey") ?? false); + } + + [Fact] + public async Task DeleteIconAsync_WithExistingIcon_RemovesIcon() + { + // Arrange + using var content = CreateProfileImageContent(); + var organization = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .Post() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "icon") + .Content(content) + .StatusCodeShouldBeOk() + ); + Assert.NotNull(organization); + Assert.NotNull(organization.IconUrl); + + // Act + var updatedOrganization = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .Delete() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "icon") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updatedOrganization); + Assert.Null(updatedOrganization.IconUrl); + + var storedOrganization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(storedOrganization); + Assert.Null(storedOrganization.IconFileName); + } + + [Fact] + public async Task GetForAdminsAsync_AsGlobalAdmin_ReturnsOrganizations() + { + // Arrange + string expectedOrganizationId = SampleDataService.TEST_ORG_ID; + + // Act + var organizations = await SendRequestAsAsync>(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "organizations") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(organizations); + Assert.Contains(organizations, organization => organization.Id == expectedOrganizationId); + } + + [Fact] + public async Task GetIconAsync_WithExistingIcon_ReturnsImage() + { + // Arrange + using var content = CreateProfileImageContent(); + var organization = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .Post() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "icon") + .Content(content) + .StatusCodeShouldBeOk() + ); + Assert.NotNull(organization); + Assert.NotNull(organization.IconUrl); + string iconPath = organization.IconUrl.TrimStart('/'); + + // Act + var response = await SendRequestAsync(r => r + .BaseUri(_server.BaseAddress) + .AppendPath(iconPath) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.Equal("image/png", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task IsNameAvailableAsync_WithExistingName_ReturnsCreated() + { + // Arrange + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + + // Act & Assert + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", "check-name") + .QueryString("name", organization.Name) + .StatusCodeShouldBeCreated() + ); + } + + [Fact] + public Task IsNameAvailableAsync_WithNewName_ReturnsNoContent() + { + // Arrange + string name = "API Surface Organization " + Guid.NewGuid().ToString("N"); + + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", "check-name") + .QueryString("name", name) + .StatusCodeShouldBeNoContent() + ); + } + + [Fact] + public async Task PlanStatsAsync_AsGlobalAdmin_ReturnsPlanStats() + { + // Arrange + const string path = "stats"; + + // Act + var stats = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("admin", "organizations", path) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(stats); + } + + [Fact] + public async Task PostDataAsync_WithValidKey_PersistsDataKey() + { + // Arrange + const string key = "ApiSurfaceKey"; + const string value = "ApiSurfaceValue"; + + // Act + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "data", key) + .Content(new ValueFromBody(value)) + .StatusCodeShouldBeOk() + ); + + // Assert + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + Assert.NotNull(organization.Data); + Assert.True(organization.Data.TryGetValue(key, out object? savedValue)); + Assert.Equal(value, savedValue); + } + private async Task SetStripeCustomerIdAsync(string organizationId, string stripeCustomerId) { var organization = await _organizationRepository.GetByIdAsync(organizationId); diff --git a/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs b/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs index ee1957ba8d..d8843620cd 100644 --- a/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/ProjectControllerTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text.Json; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Jobs; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; @@ -11,6 +12,7 @@ using FluentRest; using Foundatio.Jobs; using Foundatio.Repositories; +using Foundatio.Serializer; using Xunit; namespace Exceptionless.Tests.Controllers; @@ -37,6 +39,58 @@ protected override async Task ResetDataAsync() await service.CreateDataAsync(); } + [Fact] + public async Task AddSlackAsync_WithExistingSlackToken_ReturnsNotModified() + { + // Arrange + var project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(project); + project.Data ??= new DataDictionary(); + project.Data[Project.KnownDataKeys.SlackToken] = "already-configured"; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + // Act + var response = await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "slack") + .QueryString("code", "valid-code") + .Content(new { }) + .ExpectedStatus(HttpStatusCode.NotModified) + ); + + // Assert + Assert.Equal(HttpStatusCode.NotModified, response.StatusCode); + } + + [Fact] + public async Task AddSlackAsync_WithValidCode_PersistsSlackToken() + { + // Arrange + const string code = "valid-slack-code"; + + // Act + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "slack") + .QueryString("code", code) + .Content(new { }) + .StatusCodeShouldBeOk() + ); + + // Assert + var project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(project); + Assert.True(project.NotificationSettings.ContainsKey(Project.NotificationIntegrations.Slack)); + var token = project.GetSlackToken(GetService()); + Assert.NotNull(token); + Assert.Equal("xoxp-valid-slack-code", token.AccessToken); + Assert.Equal("team-valid-slack-code", token.TeamId); + Assert.NotNull(token.IncomingWebhook); + Assert.Equal("https://hooks.slack.test/valid-slack-code", token.IncomingWebhook.Url); + } + [Fact] public async Task DeleteAsync_ExistingProject_RemovesProject() { @@ -137,6 +191,31 @@ public Task DeleteDataAsync_NonExistentProject_ReturnsNotFound() ); } + [Fact] + public async Task DemoteTabAsync_WithExistingPromotedTab_RemovesPromotedTab() + { + // Arrange + var project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(project); + project.PromotedTabs = ["regressions", "timeline"]; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + // Act + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Delete() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "promotedtabs") + .QueryString("name", "regressions") + .StatusCodeShouldBeOk() + ); + + // Assert + var updatedProject = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(updatedProject); + Assert.DoesNotContain("regressions", updatedProject.PromotedTabs ?? []); + Assert.Contains("timeline", updatedProject.PromotedTabs ?? []); + } + [Fact] public async Task GenerateSampleDataAsync_ValidProject_QueuesSampleEvents() { @@ -553,6 +632,27 @@ await SendRequestAsync(r => r Assert.Equal(configBefore.Version, configAfter.Version); } + [Fact] + public async Task GetIntegrationNotificationSettingsAsync_WithSlackIntegration_ReturnsSettings() + { + // Arrange + var project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(project); + project.NotificationSettings[Project.NotificationIntegrations.Slack] = new NotificationSettings { ReportNewErrors = true }; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + // Act + var settings = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, Project.NotificationIntegrations.Slack, "notifications") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(settings); + Assert.True(settings.ReportNewErrors); + } + [Fact] public async Task GetNotificationSettingsAsync_AsGlobalAdmin_ReturnsAllSettings() { @@ -618,6 +718,23 @@ await SendRequestAsync(r => r Assert.True(userSettings.ReportNewErrors); } + [Fact] + public async Task GetV1ConfigAsync_WithClientAuth_ReturnsConfig() + { + // Act + var config = await SendRequestAsAsync(r => r + .BaseUri(_server.BaseAddress) + .AsFreeOrganizationClientUser() + .AppendPaths("api", "v1", "project", "config") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(config); + Assert.True(config.Version >= 0); + Assert.NotNull(config.Settings); + } + [Fact] public async Task GetV2ConfigAsync_WithClientAuth_ReturnsConfig() { @@ -902,6 +1019,31 @@ public async Task PostAsync_NewProject_MapsAllPropertiesToProject() Assert.True(project.DeleteBotDataEnabled); } + [Fact] + public async Task PromoteTabAsync_WithValidName_AddsPromotedTab() + { + // Arrange + var project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(project); + project.PromotedTabs = []; + await _projectRepository.SaveAsync(project, o => o.ImmediateConsistency()); + + // Act + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .Post() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "promotedtabs") + .QueryString("name", "regressions") + .Content(new { }) + .StatusCodeShouldBeOk() + ); + + // Assert + var updatedProject = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(updatedProject); + Assert.Contains("regressions", updatedProject.PromotedTabs ?? []); + } + [Fact] public async Task PostDataAsync_ValidKeyAndValue_PersistsData() { @@ -962,6 +1104,26 @@ public Task PostDataAsync_NonExistentProject_ReturnsNotFound() ); } + [Fact] + public async Task RemoveSlackAsync_WithoutSlackToken_ReturnsOk() + { + // Arrange + string projectId = SampleDataService.TEST_PROJECT_ID; + + // Act + await SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPaths("projects", projectId, "slack") + .StatusCodeShouldBeOk() + ); + + // Assert + var project = await _projectRepository.GetByIdAsync(projectId); + Assert.NotNull(project); + Assert.False(project.Data?.ContainsKey(Project.KnownDataKeys.SlackToken) ?? false); + } + [Fact] public async Task ResetDataAsync_ValidProject_ClearsStacksAndEvents() { @@ -1065,6 +1227,33 @@ await SendRequestAsync(r => r Assert.False(projectAfter.NotificationSettings.ContainsKey(TestConstants.UserId)); } + [Fact] + public async Task SetIntegrationNotificationSettingsAsync_WithSlackIntegration_PersistsSettings() + { + // Arrange + var settings = new NotificationSettings + { + ReportNewErrors = true, + ReportCriticalErrors = true + }; + + // Act + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, Project.NotificationIntegrations.Slack, "notifications") + .Content(settings) + .StatusCodeShouldBeOk() + ); + + // Assert + var project = await _projectRepository.GetByIdAsync(SampleDataService.TEST_PROJECT_ID); + Assert.NotNull(project); + Assert.True(project.NotificationSettings.TryGetValue(Project.NotificationIntegrations.Slack, out var savedSettings)); + Assert.True(savedSettings.ReportNewErrors); + Assert.True(savedSettings.ReportCriticalErrors); + } + [Fact] public async Task CanGetProjectListStats() { diff --git a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs index d3e48ed578..1fb788c9f2 100644 --- a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs @@ -68,6 +68,42 @@ public async Task DataSeedAsync_ExistingDataWithoutPredefinedViews_CreatesSystem Assert.Contains(seededSystemViews, view => IsPredefinedSavedView(view, "stacks", "Most Used Features")); } + [Fact] + public async Task ExportOrganizationSavedViewsAsync_AsGlobalAdmin_ReturnsOrganizationDefinitions() + { + // Arrange + var newView = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "API Surface Export View", + Slug = "api-surface-export-view", + Filter = "type:error", + ViewType = "events" + }; + + await SendRequestAsAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newView) + .StatusCodeShouldBeCreated() + ); + await RefreshDataAsync(); + + // Act + var definitions = await SendRequestAsAsync>(r => r + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views", "export") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(definitions); + Assert.Contains(definitions, definition => + String.Equals(definition.Name, "API Surface Export View", StringComparison.Ordinal) + && String.Equals(definition.ViewType, "events", StringComparison.Ordinal)); + } + [Fact] public async Task GetPredefinedAsync_GlobalAdmin_ReturnsSeedJsonShape() { @@ -106,6 +142,46 @@ public Task GetPredefinedAsync_User_ReturnsForbidden() ); } + [Fact] + public async Task PutPredefinedAsync_AsGlobalAdmin_ReplacesPredefinedDefinitions() + { + // Arrange + var definitions = new[] + { + new PredefinedSavedViewDefinition + { + Key = "events:api-surface-import", + Name = "API Surface Import", + Slug = "api-surface-import", + ViewType = "events", + Filter = "type:log", + Time = "last 24 hours", + Sort = "-date", + Columns = new Dictionary { ["message"] = true }, + ColumnOrder = ["message"], + ShowChart = true, + ShowStats = true + } + }; + + // Act + var result = await SendRequestAsAsync>(r => r + .Put() + .AsGlobalAdminUser() + .AppendPaths("saved-views", "predefined") + .Content(definitions) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(result); + var definition = Assert.Single(result); + Assert.Equal("events:api-surface-import", definition.Key); + + var savedViews = await _savedViewRepository.GetByOrganizationForUserAsync(PredefinedSavedViewsDataSeed.SystemOrganizationId, PredefinedSavedViewsDataSeed.SystemUserId); + Assert.Contains(savedViews.Documents, view => String.Equals(view.Name, "API Surface Import", StringComparison.Ordinal)); + } + [Fact] public async Task PostAsync_NewSavedView_MapsAllPropertiesToSavedView() { diff --git a/tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs b/tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs index 35e1e87793..b20f0e369b 100644 --- a/tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/TokenControllerTests.cs @@ -342,6 +342,96 @@ public async Task PostByOrganizationAsync_WithUnauthorizedOrganizationId_Returns Assert.NotNull(response); } + [Fact] + public async Task PostByOrganizationAsync_WithValidToken_CreatesTokenForRouteOrganization() + { + // Arrange + var newToken = new NewToken + { + OrganizationId = SampleDataService.TEST_ORG_ID, + ProjectId = SampleDataService.TEST_PROJECT_ID, + Scopes = [AuthorizationRoles.Client], + Notes = "Organization helper token" + }; + + // Act + var viewToken = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "tokens") + .Content(newToken) + .StatusCodeShouldBeCreated() + ); + + // Assert + Assert.NotNull(viewToken); + Assert.NotNull(viewToken.Id); + Assert.Equal(SampleDataService.TEST_ORG_ID, viewToken.OrganizationId); + Assert.Equal(SampleDataService.TEST_PROJECT_ID, viewToken.ProjectId); + Assert.Equal("Organization helper token", viewToken.Notes); + Assert.Contains(AuthorizationRoles.Client, viewToken.Scopes); + + var token = await _tokenRepository.GetByIdAsync(viewToken.Id); + Assert.NotNull(token); + Assert.Equal(SampleDataService.TEST_ORG_ID, token.OrganizationId); + Assert.Equal(SampleDataService.TEST_PROJECT_ID, token.ProjectId); + } + + [Fact] + public async Task PostByProjectAsync_WithTokenAuth_ReturnsForbidden() + { + // Arrange + const string apiKey = SampleDataService.TEST_API_KEY; + var newToken = new NewToken { Scopes = [AuthorizationRoles.Client] }; + + // Act + var response = await SendRequestAsync(r => r + .Post() + .BearerToken(apiKey) + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "tokens") + .Content(newToken) + .StatusCodeShouldBeForbidden() + ); + + // Assert + Assert.NotNull(response); + } + + [Fact] + public async Task PostByProjectAsync_WithValidProject_CreatesProjectScopedToken() + { + // Arrange + var newToken = new NewToken + { + OrganizationId = SampleDataService.FREE_ORG_ID, + ProjectId = SampleDataService.FREE_PROJECT_ID, + Scopes = [AuthorizationRoles.Client], + Notes = "Project helper token" + }; + + // Act + var viewToken = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "tokens") + .Content(newToken) + .StatusCodeShouldBeCreated() + ); + + // Assert + Assert.NotNull(viewToken); + Assert.NotNull(viewToken.Id); + Assert.Equal(SampleDataService.TEST_ORG_ID, viewToken.OrganizationId); + Assert.Equal(SampleDataService.TEST_PROJECT_ID, viewToken.ProjectId); + Assert.Equal("Project helper token", viewToken.Notes); + Assert.Contains(AuthorizationRoles.Client, viewToken.Scopes); + + var token = await _tokenRepository.GetByIdAsync(viewToken.Id); + Assert.NotNull(token); + Assert.Equal(SampleDataService.TEST_ORG_ID, token.OrganizationId); + Assert.Equal(SampleDataService.TEST_PROJECT_ID, token.ProjectId); + } + [Fact] public async Task PostAsync_WithProjectFromUnauthorizedOrganization_ReturnsValidationError() { diff --git a/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs b/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs index c76de9c938..88ad686896 100644 --- a/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/UserControllerTests.cs @@ -1,3 +1,4 @@ +using System.Net; using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; @@ -185,6 +186,45 @@ await SendRequestAsync(r => r Assert.NotNull(unchanged); } + [Fact] + public async Task DeleteAvatarAsync_WithExistingAvatar_RemovesAvatar() + { + // Arrange + var currentUser = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPath("users/me") + .StatusCodeShouldBeOk() + ); + Assert.NotNull(currentUser); + using var content = CreateProfileImageContent(); + + var updatedUser = await SendRequestAsAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("users", currentUser.Id, "avatar") + .Content(content) + .StatusCodeShouldBeOk() + ); + Assert.NotNull(updatedUser); + Assert.NotNull(updatedUser.AvatarUrl); + + // Act + var userWithoutAvatar = await SendRequestAsAsync(r => r + .Delete() + .AsGlobalAdminUser() + .AppendPaths("users", currentUser.Id, "avatar") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(userWithoutAvatar); + Assert.Null(userWithoutAvatar.AvatarUrl); + + var storedUser = await _userRepository.GetByIdAsync(currentUser.Id); + Assert.NotNull(storedUser); + Assert.Null(storedUser.AvatarFileName); + } + [Fact] public Task DeleteCurrentUserAsync_AnonymousUser_ReturnsUnauthorized() { @@ -195,6 +235,28 @@ public Task DeleteCurrentUserAsync_AnonymousUser_ReturnsUnauthorized() ); } + [Fact] + public async Task DeleteCurrentUserAsync_WithOrganizationMembership_ReturnsBadRequest() + { + // Arrange + var currentUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(currentUser); + Assert.NotEmpty(currentUser.OrganizationIds); + + // Act + await SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPath("users/me") + .StatusCodeShouldBeBadRequest() + ); + + // Assert + var storedUser = await _userRepository.GetByIdAsync(currentUser.Id); + Assert.NotNull(storedUser); + Assert.Contains(SampleDataService.TEST_ORG_ID, storedUser.OrganizationIds); + } + [Fact] public async Task GetAsync_AnonymousUser_ReturnsUnauthorized() { @@ -247,6 +309,40 @@ public async Task GetAsync_ValidId_ReturnsUser() Assert.Equal(SampleDataService.TEST_USER_EMAIL, user.EmailAddress); } + [Fact] + public async Task GetAvatarAsync_WithExistingAvatar_ReturnsImage() + { + // Arrange + var currentUser = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPath("users/me") + .StatusCodeShouldBeOk() + ); + Assert.NotNull(currentUser); + using var content = CreateProfileImageContent(); + + var updatedUser = await SendRequestAsAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("users", currentUser.Id, "avatar") + .Content(content) + .StatusCodeShouldBeOk() + ); + Assert.NotNull(updatedUser); + Assert.NotNull(updatedUser.AvatarUrl); + string avatarPath = updatedUser.AvatarUrl.TrimStart('/'); + + // Act + var response = await SendRequestAsync(r => r + .BaseUri(_server.BaseAddress) + .AppendPath(avatarPath) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.Equal("image/png", response.Content.Headers.ContentType?.MediaType); + } + [Fact] public Task GetByOrganizationAsync_AnonymousUser_ReturnsUnauthorized() { @@ -734,13 +830,68 @@ public async Task UpdateEmailAddressAsync_ValidEmail_ReturnsResult() } [Fact] - public Task VerifyAsync_InvalidToken_ReturnsNotFound() + public async Task VerifyAsync_ExpiredToken_ReturnsValidationProblem() { - return SendRequestAsync(r => r + // Arrange + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + user.ResetVerifyEmailAddressTokenAndExpiration(TimeProvider); + user.VerifyEmailAddressTokenExpiration = TimeProvider.GetUtcNow().UtcDateTime.AddMinutes(-1); + await _userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache()); + string token = Assert.IsType(user.VerifyEmailAddressToken); + + // Act + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("users", "verify-email-address", token) + .StatusCodeShouldBeUnprocessableEntity() + ); + + // Assert + var updatedUser = await _userRepository.GetByIdAsync(user.Id); + Assert.NotNull(updatedUser); + Assert.False(updatedUser.IsEmailAddressVerified); + } + + [Fact] + public async Task VerifyAsync_InvalidToken_ReturnsNotFound() + { + // Arrange + const string token = "invalidtoken1234567890ab"; + + // Act + var response = await SendRequestAsync(r => r .AsGlobalAdminUser() - .AppendPaths("users", "verify-email-address", "invalidtoken1234567890ab") + .AppendPaths("users", "verify-email-address", token) .StatusCodeShouldBeNotFound() ); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task VerifyAsync_ValidToken_VerifiesEmailAddress() + { + // Arrange + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + user.ResetVerifyEmailAddressTokenAndExpiration(TimeProvider); + await _userRepository.SaveAsync(user, o => o.ImmediateConsistency().Cache()); + string token = Assert.IsType(user.VerifyEmailAddressToken); + + // Act + await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("users", "verify-email-address", token) + .StatusCodeShouldBeOk() + ); + + // Assert + var updatedUser = await _userRepository.GetByIdAsync(user.Id); + Assert.NotNull(updatedUser); + Assert.True(updatedUser.IsEmailAddressVerified); + Assert.Null(updatedUser.VerifyEmailAddressToken); } private Task CreateOAuthApplicationAsync(string clientId, string name) diff --git a/tests/Exceptionless.Tests/Controllers/WebHookControllerTests.cs b/tests/Exceptionless.Tests/Controllers/WebHookControllerTests.cs index ae30860104..a0561e847f 100644 --- a/tests/Exceptionless.Tests/Controllers/WebHookControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/WebHookControllerTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Exceptionless.Core.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Utility; @@ -358,6 +359,27 @@ public async Task PostAsync_WithAllEventTypes_CreatesWebHook() Assert.Equal(3, persistedHook.EventTypes.Length); } + [Fact] + public async Task Test_WithGetRequest_ReturnsZapierTestMessages() + { + // Arrange + string[] expectedMessages = ["Test message 1.", "Test message 2."]; + + // Act + var messages = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPaths("webhooks", "test") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(messages); + Assert.Contains(messages, message => + message.Id == 1 && String.Equals(message.Message, expectedMessages[0], StringComparison.Ordinal)); + Assert.Contains(messages, message => + message.Id == 2 && String.Equals(message.Message, expectedMessages[1], StringComparison.Ordinal)); + } + [Fact] public async Task UnsubscribeAsync_ExistingZapierHook_RemovesWebHook() { @@ -414,4 +436,8 @@ public Task UnsubscribeAsync_MissingUrl_ReturnsNotFound() .StatusCodeShouldBeNotFound() ); } + + private sealed record ZapierTestMessage( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("message")] string Message); } diff --git a/tests/Exceptionless.Tests/IntegrationTestsBase.cs b/tests/Exceptionless.Tests/IntegrationTestsBase.cs index 873c902192..118292c4f4 100644 --- a/tests/Exceptionless.Tests/IntegrationTestsBase.cs +++ b/tests/Exceptionless.Tests/IntegrationTestsBase.cs @@ -13,6 +13,7 @@ using Exceptionless.Tests.Extensions; using Exceptionless.Tests.Mail; using Exceptionless.Tests.Utility; +using Exceptionless.Web.Security; using FluentRest; using Foundatio.Caching; using Foundatio.Jobs; @@ -106,6 +107,7 @@ protected virtual void RegisterServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); + services.ReplaceSingleton(); services.AddSingleton(); services.AddTransient(); @@ -216,6 +218,16 @@ protected FluentClient CreateFluentClient() return new FluentClient(CreateHttpClient(), new JsonContentSerializer(settings)); } + protected AppSendBuilder AppendApiV1Path(AppSendBuilder builder, params string[] segments) + { + builder.BaseUri(_server.BaseAddress).AppendPaths("api", "v1"); + + foreach (string segment in segments) + builder.AppendPath(segment); + + return builder; + } + protected async Task SendRequestAsync(Action configure) { var client = CreateFluentClient(); diff --git a/tests/Exceptionless.Tests/TestWithServices.cs b/tests/Exceptionless.Tests/TestWithServices.cs index f33df1f68b..edf53360ec 100644 --- a/tests/Exceptionless.Tests/TestWithServices.cs +++ b/tests/Exceptionless.Tests/TestWithServices.cs @@ -8,6 +8,7 @@ using Exceptionless.Tests.Authentication; using Exceptionless.Tests.Mail; using Exceptionless.Tests.Utility; +using Exceptionless.Web.Security; using Foundatio.Caching; using Foundatio.Messaging; using Foundatio.Utility; @@ -51,6 +52,7 @@ protected virtual void RegisterServices(IServiceCollection services, AppOptions services.ReplaceSingleton(_ => new ProxyTimeProvider()); services.AddSingleton(); services.AddSingleton(); + services.ReplaceSingleton(); services.AddSingleton(); services.AddTransient(); diff --git a/tests/Exceptionless.Tests/Utility/Handlers/ProjectConfigMiddlewareTests.cs b/tests/Exceptionless.Tests/Utility/Handlers/ProjectConfigMiddlewareTests.cs new file mode 100644 index 0000000000..b04e4c8358 --- /dev/null +++ b/tests/Exceptionless.Tests/Utility/Handlers/ProjectConfigMiddlewareTests.cs @@ -0,0 +1,223 @@ +using System.Security.Claims; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Utility; +using Exceptionless.Web.Utility; +using Foundatio.Serializer; +using Xunit; + +namespace Exceptionless.Tests.Utility.Handlers; + +public sealed class ProjectConfigMiddlewareTests : IntegrationTestsBase +{ + public ProjectConfigMiddlewareTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + await GetService().CreateDataAsync(); + } + + [Fact] + public async Task Invoke_ConfigRouteWithPost_CallsNext() + { + // Arrange + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateContext("/api/v2/projects/config"); + context.Request.Method = HttpMethods.Post; + + // Act + await middleware.Invoke(context); + + // Assert + Assert.True(nextCalled); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + + [Fact] + public async Task Invoke_ConfigRouteWithoutProject_ReturnsUnauthorized() + { + // Arrange + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateContext("/api/v2/projects/config"); + + // Act + await middleware.Invoke(context); + + // Assert + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + + [Fact] + public async Task Invoke_NonConfigRoute_CallsNext() + { + // Arrange + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateContext("/api/v2/projects"); + + // Act + await middleware.Invoke(context); + + // Assert + Assert.True(nextCalled); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + + [Fact] + public async Task Invoke_UnknownProject_ReturnsNotFound() + { + // Arrange + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateAuthenticatedContext("/api/v2/projects/config", TestConstants.InvalidProjectId); + + // Act + await middleware.Invoke(context); + + // Assert + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode); + } + + [Fact] + public async Task Invoke_V1ConfigRouteWithProject_ReturnsConfigurationJson() + { + // Arrange + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateAuthenticatedContext("/api/v1/project/config", SampleDataService.TEST_PROJECT_ID); + + // Act + await middleware.Invoke(context); + + // Assert + var configuration = ReadConfiguration(context); + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + Assert.Equal("application/json; charset=utf-8", context.Response.ContentType); + Assert.Equal(0, configuration.Version); + Assert.Equal("true", configuration.Settings["IncludeConditionalData"]); + } + + [Fact] + public async Task Invoke_V2ConfigRouteWithCurrentVersion_ReturnsNotModified() + { + // Arrange + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateAuthenticatedContext("/api/v2/projects/config?v=0", SampleDataService.TEST_PROJECT_ID); + + // Act + await middleware.Invoke(context); + + // Assert + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status304NotModified, context.Response.StatusCode); + Assert.Equal(String.Empty, ReadResponseBody(context)); + } + + [Fact] + public async Task Invoke_V2ConfigRouteWithProject_ReturnsConfigurationJson() + { + // Arrange + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateAuthenticatedContext("/api/v2/projects/config", SampleDataService.TEST_PROJECT_ID); + + // Act + await middleware.Invoke(context); + + // Assert + var configuration = ReadConfiguration(context); + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + Assert.Equal("application/json; charset=utf-8", context.Response.ContentType); + Assert.Equal(0, configuration.Version); + Assert.Equal("true", configuration.Settings["IncludeConditionalData"]); + } + + private ProjectConfigMiddleware CreateMiddleware(RequestDelegate next) + { + return new ProjectConfigMiddleware( + next, + GetService(), + GetService()); + } + + private static DefaultHttpContext CreateAuthenticatedContext(string pathAndQuery, string projectId) + { + var context = CreateContext(pathAndQuery); + var token = new Token + { + Id = SampleDataService.TEST_API_KEY, + Type = TokenType.Access, + OrganizationId = SampleDataService.TEST_ORG_ID, + ProjectId = projectId + }; + + context.User = new ClaimsPrincipal(token.ToIdentity()); + return context; + } + + private static DefaultHttpContext CreateContext(string pathAndQuery) + { + var context = new DefaultHttpContext(); + context.Request.Method = HttpMethods.Get; + string[] parts = pathAndQuery.Split('?', 2); + context.Request.Path = parts[0]; + if (parts.Length == 2) + context.Request.QueryString = new QueryString("?" + parts[1]); + + context.Response.Body = new MemoryStream(); + return context; + } + + private ClientConfiguration ReadConfiguration(DefaultHttpContext context) + { + string json = ReadResponseBody(context); + var configuration = GetService().Deserialize(json); + Assert.NotNull(configuration); + return configuration; + } + + private static string ReadResponseBody(DefaultHttpContext context) + { + context.Response.Body.Position = 0; + using var reader = new StreamReader(context.Response.Body, leaveOpen: true); + return reader.ReadToEnd(); + } +} diff --git a/tests/Exceptionless.Tests/Utility/TestOAuthProviderClient.cs b/tests/Exceptionless.Tests/Utility/TestOAuthProviderClient.cs new file mode 100644 index 0000000000..969236d4a0 --- /dev/null +++ b/tests/Exceptionless.Tests/Utility/TestOAuthProviderClient.cs @@ -0,0 +1,65 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; +using Exceptionless.Web.Security; +using OAuth2.Models; + +namespace Exceptionless.Tests.Utility; + +public sealed class TestOAuthProviderClient : IOAuthProviderClient +{ + public Task GetFacebookUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) + { + return GetUserInfoAsync("Facebook", authInfo); + } + + public Task GetGitHubUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) + { + return GetUserInfoAsync("GitHub", authInfo); + } + + public Task GetGoogleUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) + { + return GetUserInfoAsync("Google", authInfo); + } + + public Task GetMicrosoftUserInfoAsync(ExternalAuthInfo authInfo, string appId, string appSecret) + { + return GetUserInfoAsync("WindowsLive", authInfo); + } + + public Task GetSlackAccessTokenAsync(string code) + { + return Task.FromResult(new SlackToken + { + AccessToken = $"xoxp-{code}", + Scopes = ["incoming-webhook"], + TeamId = $"team-{code}", + TeamName = "Test Team", + UserId = $"user-{code}", + IncomingWebhook = new SlackToken.IncomingWebHook + { + Channel = "#general", + ChannelId = $"channel-{code}", + ConfigurationUrl = $"https://slack.test/config/{code}", + Url = $"https://hooks.slack.test/{code}" + } + }); + } + + public static string GetEmailAddress(string providerUserId) + { + return $"{providerUserId}@exceptionless.test"; + } + + private static Task GetUserInfoAsync(string providerName, ExternalAuthInfo authInfo) + { + return Task.FromResult(new UserInfo + { + Id = authInfo.Code, + ProviderName = providerName, + Email = GetEmailAddress(authInfo.Code), + FirstName = providerName, + LastName = "User" + }); + } +}