diff --git a/src/Exceptionless.Core/Utility/IConnectionMapping.cs b/src/Exceptionless.Core/Utility/IConnectionMapping.cs index 4965cbca7f..e8a22dcdb9 100644 --- a/src/Exceptionless.Core/Utility/IConnectionMapping.cs +++ b/src/Exceptionless.Core/Utility/IConnectionMapping.cs @@ -12,28 +12,45 @@ public interface IConnectionMapping public class ConnectionMapping : IConnectionMapping { - private readonly ConcurrentDictionary> _connections = new(); + private readonly ConcurrentDictionary _connections = new(); + + internal int TrackedKeyCount => _connections.Count; public Task AddAsync(string key, string connectionId) { if (key is null) return Task.CompletedTask; - _connections.AddOrUpdate(key, [.. new[] { connectionId }], (_, hs) => + while (true) { - hs.Add(connectionId); - return hs; - }); + var connections = _connections.GetOrAdd(key, _ => new ConnectionSet()); + + lock (connections.SyncRoot) + { + if (connections.IsDetachedFromMap) + continue; - return Task.CompletedTask; + connections.ConnectionIds.Add(connectionId); + return Task.CompletedTask; + } + } } public Task> GetConnectionsAsync(string key) { if (key is null) - return Task.FromResult>(new List()); + return Task.FromResult>([]); - return Task.FromResult>(_connections.GetOrAdd(key, [])); + if (!_connections.TryGetValue(key, out var connections)) + return Task.FromResult>([]); + + lock (connections.SyncRoot) + { + if (connections.IsDetachedFromMap) + return Task.FromResult>([]); + + return Task.FromResult>([.. connections.ConnectionIds]); + } } public Task GetConnectionCountAsync(string key) @@ -41,10 +58,13 @@ public Task GetConnectionCountAsync(string key) if (key is null) return Task.FromResult(0); - if (_connections.TryGetValue(key, out var connections)) - return Task.FromResult(connections.Count); + if (!_connections.TryGetValue(key, out var connections)) + return Task.FromResult(0); - return Task.FromResult(0); + lock (connections.SyncRoot) + { + return Task.FromResult(connections.IsDetachedFromMap ? 0 : connections.ConnectionIds.Count); + } } public Task RemoveAsync(string key, string connectionId) @@ -52,23 +72,31 @@ public Task RemoveAsync(string key, string connectionId) if (key is null) return Task.CompletedTask; - bool shouldRemove = false; - _connections.AddOrUpdate(key, [], (_, hs) => + if (!_connections.TryGetValue(key, out var connections)) + return Task.CompletedTask; + + lock (connections.SyncRoot) { - hs.Remove(connectionId); - if (hs.Count == 0) - shouldRemove = true; + if (connections.IsDetachedFromMap) + return Task.CompletedTask; - return hs; - }); + if (!connections.ConnectionIds.Remove(connectionId)) + return Task.CompletedTask; - if (!shouldRemove) - return Task.CompletedTask; + if (connections.ConnectionIds.Count is not 0) + return Task.CompletedTask; - if (_connections.TryRemove(key, out var connections) && connections.Count > 0) - _connections.TryAdd(key, connections); + connections.IsDetachedFromMap = true; + _connections.TryRemove(key, out _); + return Task.CompletedTask; + } + } - return Task.CompletedTask; + private sealed class ConnectionSet + { + public object SyncRoot { get; } = new(); + public HashSet ConnectionIds { get; } = []; + public bool IsDetachedFromMap { get; set; } } } diff --git a/src/Exceptionless.Web/Hubs/MessageBusBroker.cs b/src/Exceptionless.Web/Hubs/MessageBusBroker.cs index e9397869da..fe059935db 100644 --- a/src/Exceptionless.Web/Hubs/MessageBusBroker.cs +++ b/src/Exceptionless.Web/Hubs/MessageBusBroker.cs @@ -58,25 +58,25 @@ private async Task OnUserMembershipChangedAsync(UserMembershipChanged userMember _logger.LogTrace("Attempting to update user {User} active groups for {UserConnectionCount} connections", userMembershipChanged.UserId, userConnectionIds.Count); foreach (string connectionId in userConnectionIds) { - if (userMembershipChanged.ChangeType == ChangeType.Added) + if (userMembershipChanged.ChangeType is ChangeType.Added) await _connectionMapping.GroupAddAsync(userMembershipChanged.OrganizationId, connectionId); - else if (userMembershipChanged.ChangeType == ChangeType.Removed) + else if (userMembershipChanged.ChangeType is ChangeType.Removed) await _connectionMapping.GroupRemoveAsync(userMembershipChanged.OrganizationId, connectionId); } await GroupSendAsync(userMembershipChanged.OrganizationId, userMembershipChanged); } - private async Task OnEntityChangedAsync(EntityChanged ec, CancellationToken cancellationToken = default) + internal async Task OnEntityChangedAsync(EntityChanged ec, CancellationToken cancellationToken = default) { if (ec is null) return; var entityChanged = ExtendedEntityChanged.Create(ec); - if (UserTypeName == entityChanged.Type) + if (String.Equals(UserTypeName, entityChanged.Type, StringComparison.Ordinal)) { // It's pointless to send a user added message to the new user. - if (entityChanged.ChangeType == ChangeType.Added) + if (entityChanged.ChangeType is ChangeType.Added) { _logger.LogTrace("Ignoring {UserTypeName} message for added user: {UserId}", UserTypeName, entityChanged.Id); return; @@ -97,22 +97,43 @@ private async Task OnEntityChangedAsync(EntityChanged ec, CancellationToken canc } // Only allow specific token messages to be sent down to the client. - if (TokenTypeName == entityChanged.Type) + if (String.Equals(TokenTypeName, entityChanged.Type, StringComparison.Ordinal)) { string? userId = entityChanged.Data.GetValueOrDefault(ExtendedEntityChanged.KnownKeys.UserId); + bool isAuthToken = entityChanged.Data.GetValueOrDefault(ExtendedEntityChanged.KnownKeys.IsAuthenticationToken); + if (userId is not null) { var userConnectionIds = await _connectionMapping.GetUserIdConnectionsAsync(userId); - _logger.LogTrace("Sending {TokenTypeName} message for added user: {UserId} (to {UserConnectionCount} connections)", TokenTypeName, userId, userConnectionIds.Count); + + // Auth token removed = logout. Close sockets immediately without sending; + // there is no point delivering a message to a connection we are about to tear down. + if (isAuthToken && entityChanged.ChangeType is ChangeType.Removed) + { + _logger.LogTrace("Auth token removed for user {UserId}; closing {ConnectionCount} WebSocket connection(s)", userId, userConnectionIds.Count); + string? organizationId = entityChanged.OrganizationId; + foreach (string connectionId in userConnectionIds) + { + if (organizationId is { Length: > 0 }) + await _connectionMapping.GroupRemoveAsync(organizationId, connectionId); + + await _connectionMapping.UserIdRemoveAsync(userId, connectionId); + await _connectionManager.RemoveWebSocketAsync(connectionId); + } + + return; + } + + _logger.LogTrace("Sending {TokenTypeName} message for user: {UserId} (to {UserConnectionCount} connections)", TokenTypeName, userId, userConnectionIds.Count); foreach (string connectionId in userConnectionIds) await TypedSendAsync(connectionId, entityChanged); return; } - if (entityChanged.Data.GetValueOrDefault(ExtendedEntityChanged.KnownKeys.IsAuthenticationToken)) + if (isAuthToken) { - _logger.LogTrace("Ignoring {TokenTypeName} Authentication Token message: {UserId}", TokenTypeName, entityChanged.Id); + _logger.LogTrace("Ignoring {TokenTypeName} Authentication Token message: {TokenId}", TokenTypeName, entityChanged.Id); return; } @@ -163,7 +184,7 @@ private Task OnSystemNotificationAsync(SystemNotification notification, Cancella private async Task GroupSendAsync(string group, object value) { var connectionIds = await _connectionMapping.GetGroupConnectionsAsync(group); - if (connectionIds.Count == 0) + if (connectionIds.Count is 0) { _logger.LogTrace("Ignoring group message to {Group}: No Connections", group); return; diff --git a/src/Exceptionless.Web/Properties/AssemblyInfo.cs b/src/Exceptionless.Web/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..5be2f7fe08 --- /dev/null +++ b/src/Exceptionless.Web/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Exceptionless.Tests")] diff --git a/src/Exceptionless.Web/Utility/Handlers/OverageMiddleware.cs b/src/Exceptionless.Web/Utility/Handlers/OverageMiddleware.cs index 384783aaed..66393479f0 100644 --- a/src/Exceptionless.Web/Utility/Handlers/OverageMiddleware.cs +++ b/src/Exceptionless.Web/Utility/Handlers/OverageMiddleware.cs @@ -48,14 +48,15 @@ public async Task Invoke(HttpContext context) bool tooBig = false; if (String.Equals(context.Request.Method, "POST", StringComparison.OrdinalIgnoreCase) && context.Request.Headers is not null) { - if (context.Request.Headers.ContentLength is <= 0) + long? contentLength = context.Request.Headers.ContentLength; + if (contentLength is <= 0) { AppDiagnostics.PostsBlocked.Add(1); context.Response.StatusCode = StatusCodes.Status411LengthRequired; return; } - long size = context.Request.Headers.ContentLength.GetValueOrDefault(); + long size = contentLength.GetValueOrDefault(); if (size > 0) AppDiagnostics.PostsSize.Record(size); @@ -71,7 +72,6 @@ public async Task Invoke(HttpContext context) } } - // block large submissions, client should break them up or remove some of the data. if (tooBig) { @@ -90,9 +90,9 @@ public async Task Invoke(HttpContext context) return; } - // if user auth, check to see if the org is suspended + // if user auth, check to see if the organization is suspended // api tokens are marked as suspended immediately - if (context.Request.GetAuthType() == AuthType.User) + if (context.Request.GetAuthType() is AuthType.User) { AppDiagnostics.PostsBlocked.Add(1); var organization = await _organizationRepository.GetByIdAsync(organizationId, o => o.Cache()); diff --git a/tests/Exceptionless.Tests/Controllers/ControllerManifestTests.cs b/tests/Exceptionless.Tests/Controllers/ControllerManifestTests.cs new file mode 100644 index 0000000000..176af58b8b --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/ControllerManifestTests.cs @@ -0,0 +1,228 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Exceptionless.Web.Controllers; +using Foundatio.Xunit; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Xunit; + +namespace Exceptionless.Tests.Controllers; + +public sealed class ControllerManifestTests(ITestOutputHelper output) : TestWithLoggingBase(output) +{ + [Fact] + public async Task GetControllerManifest_AllEndpoints_ReturnsExpectedBaseline() + { + // Arrange + string baselinePath = Path.Join(AppContext.BaseDirectory, "Controllers", "Data", "controller-manifest.json"); + + // Act + string actualJson = BuildManifestJson(); + + // Set UPDATE_SNAPSHOTS=true to regenerate the baseline file. + if (String.Equals(Environment.GetEnvironmentVariable("UPDATE_SNAPSHOTS"), "true", StringComparison.OrdinalIgnoreCase)) + { + // Write to the source tree so the change produces a real git diff. + string sourcePath = Path.GetFullPath(Path.Join(AppContext.BaseDirectory, "..", "..", "..", "Controllers", "Data", "controller-manifest.json")); + await File.WriteAllTextAsync(sourcePath, actualJson, TestContext.Current.CancellationToken); + + return; + } + + // Assert + string expectedJson = (await File.ReadAllTextAsync(baselinePath, TestContext.Current.CancellationToken)).Replace("\r\n", "\n"); + actualJson = actualJson.Replace("\r\n", "\n"); + Assert.Equal(expectedJson, actualJson); + } + + internal static string BuildManifestJson() + { + var manifest = GetEndpoints() + .OrderBy(endpoint => endpoint.Route, StringComparer.Ordinal) + .ThenBy(endpoint => endpoint.HttpMethod, StringComparer.Ordinal) + .ThenBy(endpoint => endpoint.Controller, StringComparer.Ordinal) + .ThenBy(endpoint => endpoint.Action, StringComparer.Ordinal) + .ToArray(); + + return JsonSerializer.Serialize(manifest, new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }); + } + + private static IEnumerable GetEndpoints() + { + var controllerTypes = typeof(AuthController).Assembly.GetTypes() + .Where(type => !type.IsAbstract) + .Where(type => typeof(ControllerBase).IsAssignableFrom(type)) + .Where(type => type.Namespace is not null + && (type.Namespace.StartsWith("Exceptionless.Web.Controllers", StringComparison.Ordinal) + || type.Namespace.StartsWith("Exceptionless.App.Controllers", StringComparison.Ordinal))) + .OrderBy(type => type.FullName, StringComparer.Ordinal); + + foreach (var controllerType in controllerTypes) + { + var controllerRoutes = controllerType.GetCustomAttributes(true) + .Select(attribute => attribute.Template) + .DefaultIfEmpty(null) + .ToArray(); + var controllerAttributes = controllerType.GetCustomAttributes(true).ToArray(); + + foreach (var method in controllerType.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly) + .Where(method => !method.IsSpecialName) + .Where(method => !method.GetCustomAttributes(true).Any()) + .OrderBy(method => method.Name, StringComparer.Ordinal)) + { + var httpAttributes = method.GetCustomAttributes(true).ToArray(); + if (httpAttributes.Length == 0) + continue; + + var methodRouteAttributes = method.GetCustomAttributes(true) + .OfType() + .Where(attribute => attribute.GetType() == typeof(RouteAttribute)) + .ToArray(); + var methodAttributes = method.GetCustomAttributes(true).ToArray(); + + foreach (var controllerRoute in controllerRoutes) + { + foreach (var httpAttribute in httpAttributes) + { + var routeTemplates = ResolveMethodRouteTemplates(httpAttribute, methodRouteAttributes); + string? routeName = httpAttribute.Name ?? methodRouteAttributes.FirstOrDefault()?.Name; + + foreach (var httpMethod in httpAttribute.HttpMethods.OrderBy(value => value, StringComparer.Ordinal)) + { + foreach (var routeTemplate in routeTemplates) + { + yield return new ControllerEndpointManifest + { + Controller = controllerType.Name, + Action = method.Name, + HttpMethod = httpMethod, + Route = CombineRouteTemplates(controllerRoute, routeTemplate), + Name = routeName, + Authorization = GetAuthorizationAttributes(controllerAttributes, methodAttributes), + Consumes = GetContentTypes(controllerAttributes, methodAttributes), + Produces = GetContentTypes(controllerAttributes, methodAttributes), + Obsolete = methodAttributes.OfType().Select(attribute => attribute.Message).FirstOrDefault() + ?? controllerAttributes.OfType().Select(attribute => attribute.Message).FirstOrDefault(), + ExcludeFromDescription = IsExcludedFromDescription(controllerAttributes, methodAttributes) + }; + } + } + } + } + } + } + } + + private static string[] ResolveMethodRouteTemplates(HttpMethodAttribute httpAttribute, RouteAttribute[] methodRouteAttributes) + { + if (!String.IsNullOrEmpty(httpAttribute.Template)) + return [httpAttribute.Template]; + + if (methodRouteAttributes.Length > 0) + return methodRouteAttributes.Select(attribute => attribute.Template ?? String.Empty).ToArray(); + + return [String.Empty]; + } + + private static string CombineRouteTemplates(string? controllerTemplate, string? methodTemplate) + { + if (IsAbsoluteTemplate(methodTemplate)) + return NormalizeRoute(methodTemplate!); + + if (String.IsNullOrEmpty(controllerTemplate)) + return NormalizeRoute(methodTemplate ?? String.Empty); + + if (String.IsNullOrEmpty(methodTemplate)) + return NormalizeRoute(controllerTemplate); + + return NormalizeRoute($"{controllerTemplate.TrimEnd('/')}/{methodTemplate.TrimStart('/')}"); + } + + private static bool IsAbsoluteTemplate(string? template) + { + return !String.IsNullOrEmpty(template) && (template.StartsWith("~/", StringComparison.Ordinal) || template.StartsWith("/", StringComparison.Ordinal)); + } + + private static string NormalizeRoute(string route) + { + route = route.Trim(); + if (route.StartsWith("~/", StringComparison.Ordinal)) + route = route[1..]; + else if (!route.StartsWith("/", StringComparison.Ordinal)) + route = "/" + route; + + if (route.Length > 1) + route = route.TrimEnd('/'); + + return route; + } + + private static string[] GetAuthorizationAttributes(object[] controllerAttributes, object[] methodAttributes) + { + return controllerAttributes.Concat(methodAttributes) + .Where(attribute => attribute is AuthorizeAttribute or AllowAnonymousAttribute) + .Select(DescribeAuthorizationAttribute) + .Distinct(StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + } + + private static string DescribeAuthorizationAttribute(object attribute) + { + if (attribute is AllowAnonymousAttribute) + return nameof(AllowAnonymousAttribute).Replace("Attribute", String.Empty, StringComparison.Ordinal); + + var authorize = (AuthorizeAttribute)attribute; + var segments = new List(); + if (!String.IsNullOrWhiteSpace(authorize.Policy)) + segments.Add($"Policy={authorize.Policy}"); + if (!String.IsNullOrWhiteSpace(authorize.Roles)) + segments.Add($"Roles={authorize.Roles}"); + if (!String.IsNullOrWhiteSpace(authorize.AuthenticationSchemes)) + segments.Add($"AuthenticationSchemes={authorize.AuthenticationSchemes}"); + + return segments.Count == 0 ? "Authorize" : $"Authorize({String.Join(", ", segments)})"; + } + + private static string[] GetContentTypes(object[] controllerAttributes, object[] methodAttributes) where TAttribute : Attribute + { + return controllerAttributes.Concat(methodAttributes) + .OfType() + .SelectMany(attribute => attribute switch + { + ConsumesAttribute consumes => consumes.ContentTypes, + ProducesAttribute produces => produces.ContentTypes, + _ => [] + }) + .Distinct(StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + } + + private static bool IsExcludedFromDescription(object[] controllerAttributes, object[] methodAttributes) + { + return controllerAttributes.Concat(methodAttributes).Any(attribute => + attribute.GetType().Name == "ExcludeFromDescriptionAttribute" + || attribute is ApiExplorerSettingsAttribute { IgnoreApi: true }); + } + + private sealed record ControllerEndpointManifest + { + public required string Controller { get; init; } + public required string Action { get; init; } + public required string HttpMethod { get; init; } + public required string Route { get; init; } + public string? Name { get; init; } + public required string[] Authorization { get; init; } + public required string[] Consumes { get; init; } + public required string[] Produces { get; init; } + public string? Obsolete { get; init; } + public bool ExcludeFromDescription { get; init; } + } +} diff --git a/tests/Exceptionless.Tests/Controllers/Data/controller-manifest.json b/tests/Exceptionless.Tests/Controllers/Data/controller-manifest.json new file mode 100644 index 0000000000..94e88d48b6 --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/Data/controller-manifest.json @@ -0,0 +1,2999 @@ +[ + { + "Controller": "EventController", + "Action": "LegacyPostAsync", + "HttpMethod": "POST", + "Route": "/api/v1/error", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json", + "text/plain" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "Obsolete": "Use POST /api/v2/events", + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "LegacyPatchAsync", + "HttpMethod": "PATCH", + "Route": "/api/v1/error/{id:objectid}", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "Obsolete": "Use PATCH /api/v2/events", + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "PostV1Async", + "HttpMethod": "POST", + "Route": "/api/v1/events", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json", + "text/plain" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "Obsolete": "Use POST /api/v2/events", + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetSubmitEventV1Async", + "HttpMethod": "GET", + "Route": "/api/v1/events/submit", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "Obsolete": "Use GET /api/v2/events/submit", + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetSubmitEventV1Async", + "HttpMethod": "GET", + "Route": "/api/v1/events/submit/{type:minlength(1)}", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "Obsolete": "Use GET /api/v2/events/submit", + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "GetV1ConfigAsync", + "HttpMethod": "GET", + "Route": "/api/v1/project/config", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "Obsolete": "Use /api/v2/projects/config instead", + "ExcludeFromDescription": false + }, + { + "Controller": "WebHookController", + "Action": "SubscribeAsync", + "HttpMethod": "POST", + "Route": "/api/v1/projecthook/subscribe", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "WebHookController", + "Action": "Test", + "HttpMethod": "GET", + "Route": "/api/v1/projecthook/test", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "WebHookController", + "Action": "Test", + "HttpMethod": "POST", + "Route": "/api/v1/projecthook/test", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "WebHookController", + "Action": "UnsubscribeAsync", + "HttpMethod": "POST", + "Route": "/api/v1/projecthook/unsubscribe", + "Authorization": [ + "AllowAnonymous", + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "EventController", + "Action": "PostV1Async", + "HttpMethod": "POST", + "Route": "/api/v1/projects/{projectId:objectid}/events", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json", + "text/plain" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "Obsolete": "Use POST /api/v2/events", + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetSubmitEventV1Async", + "HttpMethod": "GET", + "Route": "/api/v1/projects/{projectId:objectid}/events/submit", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "Obsolete": "Use GET /api/v2/events/submit", + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetSubmitEventV1Async", + "HttpMethod": "GET", + "Route": "/api/v1/projects/{projectId:objectid}/events/submit/{type:minlength(1)}", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "Obsolete": "Use GET /api/v2/events/submit", + "ExcludeFromDescription": false + }, + { + "Controller": "StackController", + "Action": "AddLinkAsync", + "HttpMethod": "POST", + "Route": "/api/v1/stack/addlink", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "StackController", + "Action": "MarkFixedAsync", + "HttpMethod": "POST", + "Route": "/api/v1/stack/markfixed", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "StatusController", + "Action": "IndexAsync", + "HttpMethod": "GET", + "Route": "/api/v2/about", + "Authorization": [ + "AllowAnonymous", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "AdminController", + "Action": "Assemblies", + "HttpMethod": "GET", + "Route": "/api/v2/admin/assemblies", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "AdminController", + "Action": "ChangePlanAsync", + "HttpMethod": "POST", + "Route": "/api/v2/admin/change-plan", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "AdminController", + "Action": "EchoRequest", + "HttpMethod": "GET", + "Route": "/api/v2/admin/echo", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "AdminController", + "Action": "GetElasticsearchInfoAsync", + "HttpMethod": "GET", + "Route": "/api/v2/admin/elasticsearch", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "AdminController", + "Action": "GetElasticsearchSnapshotsAsync", + "HttpMethod": "GET", + "Route": "/api/v2/admin/elasticsearch/snapshots", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "AdminController", + "Action": "GenerateSampleEventsAsync", + "HttpMethod": "POST", + "Route": "/api/v2/admin/generate-sample-events", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "AdminController", + "Action": "RunJobAsync", + "HttpMethod": "GET", + "Route": "/api/v2/admin/maintenance/{name:minlength(1)}", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "AdminController", + "Action": "GetMigrationsAsync", + "HttpMethod": "GET", + "Route": "/api/v2/admin/migrations", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "OrganizationController", + "Action": "GetForAdminsAsync", + "HttpMethod": "GET", + "Route": "/api/v2/admin/organizations", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "OrganizationController", + "Action": "PlanStatsAsync", + "HttpMethod": "GET", + "Route": "/api/v2/admin/organizations/stats", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "AdminController", + "Action": "RequeueAsync", + "HttpMethod": "GET", + "Route": "/api/v2/admin/requeue", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "AdminController", + "Action": "SetBonusAsync", + "HttpMethod": "POST", + "Route": "/api/v2/admin/set-bonus", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "AdminController", + "Action": "SettingsRequest", + "HttpMethod": "GET", + "Route": "/api/v2/admin/settings", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "AdminController", + "Action": "GetStatsAsync", + "HttpMethod": "GET", + "Route": "/api/v2/admin/stats", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "AuthController", + "Action": "CancelResetPasswordAsync", + "HttpMethod": "POST", + "Route": "/api/v2/auth/cancel-reset-password/{token:minlength(1)}", + "Authorization": [ + "AllowAnonymous", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "AuthController", + "Action": "ChangePasswordAsync", + "HttpMethod": "POST", + "Route": "/api/v2/auth/change-password", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "AuthController", + "Action": "IsEmailAddressAvailableAsync", + "HttpMethod": "GET", + "Route": "/api/v2/auth/check-email-address/{email:minlength(1)}", + "Authorization": [ + "AllowAnonymous", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "AuthController", + "Action": "FacebookAsync", + "HttpMethod": "POST", + "Route": "/api/v2/auth/facebook", + "Authorization": [ + "AllowAnonymous", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "AuthController", + "Action": "ForgotPasswordAsync", + "HttpMethod": "GET", + "Route": "/api/v2/auth/forgot-password/{email:minlength(1)}", + "Authorization": [ + "AllowAnonymous", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "AuthController", + "Action": "GitHubAsync", + "HttpMethod": "POST", + "Route": "/api/v2/auth/github", + "Authorization": [ + "AllowAnonymous", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "AuthController", + "Action": "GoogleAsync", + "HttpMethod": "POST", + "Route": "/api/v2/auth/google", + "Authorization": [ + "AllowAnonymous", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "AuthController", + "Action": "GetIntercomTokenAsync", + "HttpMethod": "GET", + "Route": "/api/v2/auth/intercom", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "AuthController", + "Action": "LiveAsync", + "HttpMethod": "POST", + "Route": "/api/v2/auth/live", + "Authorization": [ + "AllowAnonymous", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "AuthController", + "Action": "LoginAsync", + "HttpMethod": "POST", + "Route": "/api/v2/auth/login", + "Authorization": [ + "AllowAnonymous", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "AuthController", + "Action": "LogoutAsync", + "HttpMethod": "GET", + "Route": "/api/v2/auth/logout", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "AuthController", + "Action": "ResetPasswordAsync", + "HttpMethod": "POST", + "Route": "/api/v2/auth/reset-password", + "Authorization": [ + "AllowAnonymous", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "AuthController", + "Action": "SignupAsync", + "HttpMethod": "POST", + "Route": "/api/v2/auth/signup", + "Authorization": [ + "AllowAnonymous", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "AuthController", + "Action": "RemoveExternalLoginAsync", + "HttpMethod": "POST", + "Route": "/api/v2/auth/unlink/{providerName:minlength(1)}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetAllAsync", + "HttpMethod": "GET", + "Route": "/api/v2/events", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "PostV2Async", + "HttpMethod": "POST", + "Route": "/api/v2/events", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json", + "text/plain" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetByReferenceIdAsync", + "HttpMethod": "GET", + "Route": "/api/v2/events/by-ref/{referenceId:identifier}", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "SetUserDescriptionAsync", + "HttpMethod": "POST", + "Route": "/api/v2/events/by-ref/{referenceId:identifier}/user-description", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetCountAsync", + "HttpMethod": "GET", + "Route": "/api/v2/events/count", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "RecordHeartbeatAsync", + "HttpMethod": "GET", + "Route": "/api/v2/events/session/heartbeat", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetSessionsAsync", + "HttpMethod": "GET", + "Route": "/api/v2/events/sessions", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetBySessionIdAsync", + "HttpMethod": "GET", + "Route": "/api/v2/events/sessions/{sessionId:identifier}", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetSubmitEventV2Async", + "HttpMethod": "GET", + "Route": "/api/v2/events/submit", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetSubmitEventByTypeV2Async", + "HttpMethod": "GET", + "Route": "/api/v2/events/submit/{type:minlength(1)}", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetAsync", + "HttpMethod": "GET", + "Route": "/api/v2/events/{id:objectid}", + "Name": "GetPersistentEventById", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "DeleteAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/events/{ids:objectids}", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "StatusController", + "Action": "PostReleaseNotificationAsync", + "HttpMethod": "POST", + "Route": "/api/v2/notifications/release", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "StatusController", + "Action": "RemoveSystemNotificationAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/notifications/system", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "StatusController", + "Action": "GetSystemNotificationAsync", + "HttpMethod": "GET", + "Route": "/api/v2/notifications/system", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "StatusController", + "Action": "PostSystemNotificationAsync", + "HttpMethod": "POST", + "Route": "/api/v2/notifications/system", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "OrganizationController", + "Action": "GetAllAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "OrganizationController", + "Action": "PostAsync", + "HttpMethod": "POST", + "Route": "/api/v2/organizations", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "OrganizationController", + "Action": "IsNameAvailableAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations/check-name", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "OrganizationController", + "Action": "GetInvoiceAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations/invoice/{id:minlength(10)}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "OrganizationController", + "Action": "GetAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations/{id:objectid}", + "Name": "GetOrganizationById", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "OrganizationController", + "Action": "PatchAsync", + "HttpMethod": "PATCH", + "Route": "/api/v2/organizations/{id:objectid}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "OrganizationController", + "Action": "PatchAsync", + "HttpMethod": "PUT", + "Route": "/api/v2/organizations/{id:objectid}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "OrganizationController", + "Action": "ChangePlanAsync", + "HttpMethod": "POST", + "Route": "/api/v2/organizations/{id:objectid}/change-plan", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "OrganizationController", + "Action": "DeleteDataAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/organizations/{id:objectid}/data/{key:minlength(1)}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "OrganizationController", + "Action": "PostDataAsync", + "HttpMethod": "POST", + "Route": "/api/v2/organizations/{id:objectid}/data/{key:minlength(1)}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "OrganizationController", + "Action": "RemoveFeatureAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/organizations/{id:objectid}/features/{feature:minlength(1)}", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "OrganizationController", + "Action": "SetFeatureAsync", + "HttpMethod": "POST", + "Route": "/api/v2/organizations/{id:objectid}/features/{feature:minlength(1)}", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "OrganizationController", + "Action": "GetInvoicesAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations/{id:objectid}/invoices", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "OrganizationController", + "Action": "GetPlansAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations/{id:objectid}/plans", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "OrganizationController", + "Action": "UnsuspendAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/organizations/{id:objectid}/suspend", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "OrganizationController", + "Action": "SuspendAsync", + "HttpMethod": "POST", + "Route": "/api/v2/organizations/{id:objectid}/suspend", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "OrganizationController", + "Action": "RemoveUserAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/organizations/{id:objectid}/users/{email:minlength(1)}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "OrganizationController", + "Action": "AddUserAsync", + "HttpMethod": "POST", + "Route": "/api/v2/organizations/{id:objectid}/users/{email:minlength(1)}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "OrganizationController", + "Action": "DeleteAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/organizations/{ids:objectids}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetByOrganizationAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations/{organizationId:objectid}/events", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetCountByOrganizationAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations/{organizationId:objectid}/events/count", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetSessionByOrganizationAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations/{organizationId:objectid}/events/sessions", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "GetByOrganizationAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations/{organizationId:objectid}/projects", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "IsNameAvailableAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations/{organizationId:objectid}/projects/check-name", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "SavedViewController", + "Action": "GetByOrganizationAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations/{organizationId:objectid}/saved-views", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "SavedViewController", + "Action": "PostAsync", + "HttpMethod": "POST", + "Route": "/api/v2/organizations/{organizationId:objectid}/saved-views", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "SavedViewController", + "Action": "PostPredefinedAsync", + "HttpMethod": "POST", + "Route": "/api/v2/organizations/{organizationId:objectid}/saved-views/predefined", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "SavedViewController", + "Action": "GetByViewAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations/{organizationId:objectid}/saved-views/{viewType}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "StackController", + "Action": "GetByOrganizationAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations/{organizationId:objectid}/stacks", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "TokenController", + "Action": "GetByOrganizationAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations/{organizationId:objectid}/tokens", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "TokenController", + "Action": "PostByOrganizationAsync", + "HttpMethod": "POST", + "Route": "/api/v2/organizations/{organizationId:objectid}/tokens", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "UserController", + "Action": "GetByOrganizationAsync", + "HttpMethod": "GET", + "Route": "/api/v2/organizations/{organizationId:objectid}/users", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "GetAllAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "PostAsync", + "HttpMethod": "POST", + "Route": "/api/v2/projects", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "IsNameAvailableAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/check-name", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "GetV2ConfigAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/config", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "GetAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{id:objectid}", + "Name": "GetProjectById", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "PatchAsync", + "HttpMethod": "PATCH", + "Route": "/api/v2/projects/{id:objectid}", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "PatchAsync", + "HttpMethod": "PUT", + "Route": "/api/v2/projects/{id:objectid}", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "DeleteConfigAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/projects/{id:objectid}/config", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "GetConfigAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{id:objectid}/config", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "SetConfigAsync", + "HttpMethod": "POST", + "Route": "/api/v2/projects/{id:objectid}/config", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "DeleteDataAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/projects/{id:objectid}/data", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "PostDataAsync", + "HttpMethod": "POST", + "Route": "/api/v2/projects/{id:objectid}/data", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "GetNotificationSettingsAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{id:objectid}/notifications", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=GlobalAdminPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "ProjectController", + "Action": "DemoteTabAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/projects/{id:objectid}/promotedtabs", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "PromoteTabAsync", + "HttpMethod": "POST", + "Route": "/api/v2/projects/{id:objectid}/promotedtabs", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "PromoteTabAsync", + "HttpMethod": "PUT", + "Route": "/api/v2/projects/{id:objectid}/promotedtabs", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "ResetDataAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{id:objectid}/reset-data", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "ResetDataAsync", + "HttpMethod": "POST", + "Route": "/api/v2/projects/{id:objectid}/reset-data", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "GenerateSampleDataAsync", + "HttpMethod": "POST", + "Route": "/api/v2/projects/{id:objectid}/sample-data", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "RemoveSlackAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/projects/{id:objectid}/slack", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "ProjectController", + "Action": "AddSlackAsync", + "HttpMethod": "POST", + "Route": "/api/v2/projects/{id:objectid}/slack", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "ProjectController", + "Action": "GetIntegrationNotificationSettingsAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{id:objectid}/{integration:minlength(1)}/notifications", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "ProjectController", + "Action": "SetIntegrationNotificationSettingsAsync", + "HttpMethod": "POST", + "Route": "/api/v2/projects/{id:objectid}/{integration:minlength(1)}/notifications", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "SetIntegrationNotificationSettingsAsync", + "HttpMethod": "PUT", + "Route": "/api/v2/projects/{id:objectid}/{integration:minlength(1)}/notifications", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "DeleteAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/projects/{ids:objectids}", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetByProjectAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{projectId:objectid}/events", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "PostByProjectV2Async", + "HttpMethod": "POST", + "Route": "/api/v2/projects/{projectId:objectid}/events", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json", + "text/plain" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetByReferenceIdAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{projectId:objectid}/events/by-ref/{referenceId:identifier}", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "SetUserDescriptionAsync", + "HttpMethod": "POST", + "Route": "/api/v2/projects/{projectId:objectid}/events/by-ref/{referenceId:identifier}/user-description", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetCountByProjectAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{projectId:objectid}/events/count", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetSessionByProjectAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{projectId:objectid}/events/sessions", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetBySessionIdAndProjectAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{projectId:objectid}/events/sessions/{sessionId:identifier}", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetSubmitEventByProjectV2Async", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{projectId:objectid}/events/submit", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetSubmitEventByProjectV2Async", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{projectId:objectid}/events/submit/{type:minlength(1)}", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "StackController", + "Action": "GetByProjectAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{projectId:objectid}/stacks", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "TokenController", + "Action": "GetByProjectAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{projectId:objectid}/tokens", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "TokenController", + "Action": "PostByProjectAsync", + "HttpMethod": "POST", + "Route": "/api/v2/projects/{projectId:objectid}/tokens", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "TokenController", + "Action": "GetDefaultTokenAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{projectId:objectid}/tokens/default", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "WebHookController", + "Action": "GetByProjectAsync", + "HttpMethod": "GET", + "Route": "/api/v2/projects/{projectId:objectid}/webhooks", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "StatusController", + "Action": "QueueStatsAsync", + "HttpMethod": "GET", + "Route": "/api/v2/queue-stats", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "SavedViewController", + "Action": "GetPredefinedAsync", + "HttpMethod": "GET", + "Route": "/api/v2/saved-views/predefined", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "SavedViewController", + "Action": "GetAsync", + "HttpMethod": "GET", + "Route": "/api/v2/saved-views/{id:objectid}", + "Name": "GetSavedViewById", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "SavedViewController", + "Action": "PatchAsync", + "HttpMethod": "PATCH", + "Route": "/api/v2/saved-views/{id:objectid}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "SavedViewController", + "Action": "PatchAsync", + "HttpMethod": "PUT", + "Route": "/api/v2/saved-views/{id:objectid}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "SavedViewController", + "Action": "DeletePredefinedSavedViewAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/saved-views/{id:objectid}/predefined", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "SavedViewController", + "Action": "PostPredefinedSavedViewAsync", + "HttpMethod": "POST", + "Route": "/api/v2/saved-views/{id:objectid}/predefined", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "SavedViewController", + "Action": "DeleteAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/saved-views/{ids:objectids}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "UtilityController", + "Action": "ValidateAsync", + "HttpMethod": "GET", + "Route": "/api/v2/search/validate", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "StackController", + "Action": "GetAllAsync", + "HttpMethod": "GET", + "Route": "/api/v2/stacks", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "StackController", + "Action": "AddLinkAsync", + "HttpMethod": "POST", + "Route": "/api/v2/stacks/add-link", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "StackController", + "Action": "MarkFixedAsync", + "HttpMethod": "POST", + "Route": "/api/v2/stacks/mark-fixed", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "StackController", + "Action": "GetAsync", + "HttpMethod": "GET", + "Route": "/api/v2/stacks/{id:objectid}", + "Name": "GetStackById", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "StackController", + "Action": "AddLinkAsync", + "HttpMethod": "POST", + "Route": "/api/v2/stacks/{id:objectid}/add-link", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "StackController", + "Action": "PromoteAsync", + "HttpMethod": "POST", + "Route": "/api/v2/stacks/{id:objectid}/promote", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "StackController", + "Action": "RemoveLinkAsync", + "HttpMethod": "POST", + "Route": "/api/v2/stacks/{id:objectid}/remove-link", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "StackController", + "Action": "DeleteAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/stacks/{ids:objectids}", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "StackController", + "Action": "ChangeStatusAsync", + "HttpMethod": "POST", + "Route": "/api/v2/stacks/{ids:objectids}/change-status", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "StackController", + "Action": "MarkNotCriticalAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/stacks/{ids:objectids}/mark-critical", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "StackController", + "Action": "MarkCriticalAsync", + "HttpMethod": "POST", + "Route": "/api/v2/stacks/{ids:objectids}/mark-critical", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "StackController", + "Action": "MarkFixedAsync", + "HttpMethod": "POST", + "Route": "/api/v2/stacks/{ids:objectids}/mark-fixed", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "StackController", + "Action": "SnoozeAsync", + "HttpMethod": "POST", + "Route": "/api/v2/stacks/{ids:objectids}/mark-snoozed", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "EventController", + "Action": "GetByStackAsync", + "HttpMethod": "GET", + "Route": "/api/v2/stacks/{stackId:objectid}/events", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "StripeController", + "Action": "PostAsync", + "HttpMethod": "POST", + "Route": "/api/v2/stripe", + "Authorization": [ + "AllowAnonymous", + "Authorize" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "TokenController", + "Action": "PostAsync", + "HttpMethod": "POST", + "Route": "/api/v2/tokens", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "TokenController", + "Action": "PatchAsync", + "HttpMethod": "PATCH", + "Route": "/api/v2/tokens/{id:tokens}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "TokenController", + "Action": "PatchAsync", + "HttpMethod": "PUT", + "Route": "/api/v2/tokens/{id:tokens}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "TokenController", + "Action": "GetAsync", + "HttpMethod": "GET", + "Route": "/api/v2/tokens/{id:token}", + "Name": "GetTokenById", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "TokenController", + "Action": "DeleteAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/tokens/{ids:tokens}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "UserController", + "Action": "DeleteCurrentUserAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/users/me", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "UserController", + "Action": "GetCurrentUserAsync", + "HttpMethod": "GET", + "Route": "/api/v2/users/me", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "UserController", + "Action": "UnverifyEmailAddressAsync", + "HttpMethod": "POST", + "Route": "/api/v2/users/unverify-email-address", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "text/plain" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "UserController", + "Action": "VerifyAsync", + "HttpMethod": "GET", + "Route": "/api/v2/users/verify-email-address/{token:token}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "UserController", + "Action": "GetAsync", + "HttpMethod": "GET", + "Route": "/api/v2/users/{id:objectid}", + "Name": "GetUserById", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "UserController", + "Action": "PatchAsync", + "HttpMethod": "PATCH", + "Route": "/api/v2/users/{id:objectid}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "UserController", + "Action": "PatchAsync", + "HttpMethod": "PUT", + "Route": "/api/v2/users/{id:objectid}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "UserController", + "Action": "DeleteAdminRoleAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/users/{id:objectid}/admin-role", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "UserController", + "Action": "AddAdminRoleAsync", + "HttpMethod": "POST", + "Route": "/api/v2/users/{id:objectid}/admin-role", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "UserController", + "Action": "UpdateEmailAddressAsync", + "HttpMethod": "POST", + "Route": "/api/v2/users/{id:objectid}/email-address/{email:minlength(1)}", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "UserController", + "Action": "ResendVerificationEmailAsync", + "HttpMethod": "GET", + "Route": "/api/v2/users/{id:objectid}/resend-verification-email", + "Authorization": [ + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "UserController", + "Action": "DeleteAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/users/{ids:objectids}", + "Authorization": [ + "Authorize(Policy=GlobalAdminPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "DeleteNotificationSettingsAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "GetNotificationSettingsAsync", + "HttpMethod": "GET", + "Route": "/api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "SetNotificationSettingsAsync", + "HttpMethod": "POST", + "Route": "/api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "ProjectController", + "Action": "SetNotificationSettingsAsync", + "HttpMethod": "PUT", + "Route": "/api/v2/users/{userId:objectid}/projects/{id:objectid}/notifications", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "WebHookController", + "Action": "PostAsync", + "HttpMethod": "POST", + "Route": "/api/v2/webhooks", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "WebHookController", + "Action": "SubscribeAsync", + "HttpMethod": "POST", + "Route": "/api/v2/webhooks/subscribe", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "WebHookController", + "Action": "Test", + "HttpMethod": "GET", + "Route": "/api/v2/webhooks/test", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "WebHookController", + "Action": "Test", + "HttpMethod": "POST", + "Route": "/api/v2/webhooks/test", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "WebHookController", + "Action": "UnsubscribeAsync", + "HttpMethod": "POST", + "Route": "/api/v2/webhooks/unsubscribe", + "Authorization": [ + "AllowAnonymous", + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + }, + { + "Controller": "WebHookController", + "Action": "GetAsync", + "HttpMethod": "GET", + "Route": "/api/v2/webhooks/{id:objectid}", + "Name": "GetWebHookById", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "WebHookController", + "Action": "DeleteAsync", + "HttpMethod": "DELETE", + "Route": "/api/v2/webhooks/{ids:objectids}", + "Authorization": [ + "Authorize(Policy=ClientPolicy)", + "Authorize(Policy=UserPolicy)" + ], + "Consumes": [], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": false + }, + { + "Controller": "WebHookController", + "Action": "SubscribeAsync", + "HttpMethod": "POST", + "Route": "/api/v{apiVersion:int=2}/webhooks/subscribe", + "Authorization": [ + "Authorize(Policy=ClientPolicy)" + ], + "Consumes": [ + "application/json" + ], + "Produces": [ + "application/json", + "application/problem\u002Bjson" + ], + "ExcludeFromDescription": true + } +] \ No newline at end of file diff --git a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs index 0f09c53ee2..913036dc60 100644 --- a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs @@ -1942,4 +1942,52 @@ private string ToPrettyJson(string json) }; return JsonSerializer.Serialize(document.RootElement, prettyJsonOptions); } + + [Fact] + public async Task LegacyPatchAsync_WithPascalCaseLegacyFields_MapsToUserDescription() + { + // Arrange — submit event with a reference ID matching an objectid format + const string referenceId = "507f1f77bcf86cd799439011"; + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationClientUser() + .AppendPath("events") + .Content($$""" + { + "type": "log", + "message": "Legacy patch test", + "reference_id": "{{referenceId}}" + } + """, "application/json") + .StatusCodeShouldBeAccepted() + ); + + await GetService().RunAsync(TestCancellationToken); + await RefreshDataAsync(); + + // Act — v1 clients sent PascalCase "UserEmail" / "UserDescription" + await SendRequestAsync(r => r + .Patch() + .BaseUri(_server.BaseAddress) + .AsTestOrganizationClientUser() + .AppendPaths("api", "v1", "error", referenceId) + .Content(""" + { + "UserEmail": "legacy@exceptionless.test", + "UserDescription": "Legacy description" + } + """, "application/json") + .StatusCodeShouldBeAccepted() + ); + + await GetService().RunAsync(TestCancellationToken); + await RefreshDataAsync(); + + // Assert + var ev = (await _eventRepository.GetByReferenceIdAsync(SampleDataService.TEST_PROJECT_ID, referenceId)).Documents.Single(); + var userDescription = ev.GetUserDescription(_jsonSerializerOptions); + Assert.NotNull(userDescription); + Assert.Equal("legacy@exceptionless.test", userDescription.EmailAddress); + Assert.Equal("Legacy description", userDescription.Description); + } } diff --git a/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs index 23e1a98cfc..dd6e08830b 100644 --- a/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OpenApiControllerTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Exceptionless.Tests.Extensions; using Xunit; @@ -30,4 +31,76 @@ public async Task GetOpenApiJson_Default_ReturnsExpectedBaseline() Assert.Equal(expectedJson, actualJson); } + + [Fact] + public async Task GetOpenApiJson_ContainsExpectedRoutesOperationsAndResponses() + { + using var document = await GetOpenApiDocumentAsync(); + var paths = document.RootElement.GetProperty("paths"); + + Assert.True(paths.TryGetProperty("/api/v2/auth/login", out var loginPath)); + Assert.True(loginPath.TryGetProperty("post", out var loginPost)); + Assert.True(loginPost.TryGetProperty("requestBody", out _)); + AssertResponseCodes(loginPost, "200", "401", "422"); + + Assert.True(paths.TryGetProperty("/api/v2/auth/logout", out var logoutPath)); + Assert.True(logoutPath.TryGetProperty("get", out var logoutGet)); + AssertResponseCodes(logoutGet, "200", "401", "403"); + + Assert.True(paths.TryGetProperty("/api/v2/projects", out var projectsPath)); + Assert.True(projectsPath.TryGetProperty("post", out var projectsPost)); + Assert.True(projectsPost.TryGetProperty("requestBody", out _)); + AssertResponseCodes(projectsPost, "201"); + + Assert.True(paths.TryGetProperty("/api/v2/events/by-ref/{referenceId}/user-description", out var userDescriptionPath)); + Assert.True(userDescriptionPath.TryGetProperty("post", out var userDescriptionPost)); + Assert.True(userDescriptionPost.TryGetProperty("requestBody", out _)); + AssertResponseCodes(userDescriptionPost, "202"); + } + + [Fact] + public async Task GetOpenApiJson_ContainsExpectedSchemasAndSecuritySchemes() + { + using var document = await GetOpenApiDocumentAsync(); + var components = document.RootElement.GetProperty("components"); + var schemas = components.GetProperty("schemas"); + var securitySchemes = components.GetProperty("securitySchemes"); + + Assert.True(schemas.TryGetProperty("Login", out _)); + Assert.True(schemas.TryGetProperty("Signup", out _)); + Assert.True(schemas.TryGetProperty("NewProject", out _)); + Assert.True(schemas.TryGetProperty("TokenResult", out _)); + Assert.True(schemas.TryGetProperty("ViewOrganization", out _)); + + Assert.True(securitySchemes.TryGetProperty("Basic", out var basic)); + Assert.Equal("http", basic.GetProperty("type").GetString()); + Assert.Equal("basic", basic.GetProperty("scheme").GetString()); + + Assert.True(securitySchemes.TryGetProperty("Bearer", out var bearer)); + Assert.Equal("http", bearer.GetProperty("type").GetString()); + Assert.Equal("bearer", bearer.GetProperty("scheme").GetString()); + + Assert.True(securitySchemes.TryGetProperty("Token", out var token)); + Assert.Equal("apiKey", token.GetProperty("type").GetString()); + Assert.Equal("access_token", token.GetProperty("name").GetString()); + } + + private async Task GetOpenApiDocumentAsync() + { + var response = await SendRequestAsync(r => r + .BaseUri(_server.BaseAddress) + .AppendPaths("docs", "v2", "openapi.json") + .StatusCodeShouldBeOk() + ); + + string json = await response.Content.ReadAsStringAsync(TestCancellationToken); + return JsonDocument.Parse(json); + } + + private static void AssertResponseCodes(JsonElement operation, params string[] expectedStatusCodes) + { + var responses = operation.GetProperty("responses"); + foreach (string statusCode in expectedStatusCodes) + Assert.True(responses.TryGetProperty(statusCode, out _), $"Expected response status code '{statusCode}'."); + } } diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index 2a7ecd6a36..93c4faf491 100644 --- a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs @@ -1544,4 +1544,26 @@ public async Task PatchAsync_UpdateName_ReturnsUpdatedOrganization() Assert.NotNull(persisted); Assert.Equal("Updated Acme", persisted.Name); } + + [Fact] + public async Task PatchAsync_EmptyJsonBody_ReturnsOriginalOrganizationUnchanged() + { + // Arrange + var originalOrganization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(originalOrganization); + + // Act + var updated = await SendRequestAsAsync(r => r + .Patch() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID) + .Content("{}", "application/json") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updated); + Assert.Equal(originalOrganization.Name, updated.Name); + Assert.Equal(originalOrganization.UpdatedUtc, updated.UpdatedUtc); + } } diff --git a/tests/Exceptionless.Tests/Controllers/ValidationSnapshotTests.cs b/tests/Exceptionless.Tests/Controllers/ValidationSnapshotTests.cs new file mode 100644 index 0000000000..ca2621fb7d --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/ValidationSnapshotTests.cs @@ -0,0 +1,100 @@ +using System.Text.Json; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Extensions; +using Exceptionless.Tests.Utility; +using Xunit; + +namespace Exceptionless.Tests.Controllers; + +public sealed class ValidationSnapshotTests : IntegrationTestsBase +{ + public ValidationSnapshotTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) { } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + await GetService().CreateDataAsync(); + } + + [Fact] + public async Task PostAsync_EmptyProjectName_ReturnsProblemDetailsWithCamelCaseProperties() + { + // Act + var response = await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPath("projects") + .Content(new + { + organization_id = SampleDataService.TEST_ORG_ID, + name = String.Empty + }) + .StatusCodeShouldBeBadRequest() + ); + + using var document = await AssertProblemDetailsAsync(response, StatusCodes.Status400BadRequest); + Assert.Equal("Project name is required.", document.RootElement.GetProperty("title").GetString()); + } + + [Fact] + public async Task PatchAsync_EmptyBody_ReturnsProblemDetailsWithCamelCaseProperties() + { + // Act + var response = await SendRequestAsync(r => r + .Patch() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID) + .StatusCodeShouldBeBadRequest() + ); + + // Assert + using var _ = await AssertProblemDetailsAsync(response, StatusCodes.Status400BadRequest, expectErrors: true); + } + + [Fact] + public async Task SetUserDescriptionAsync_EmptyPayload_ReturnsValidationProblemDetailsWithCamelCaseProperties() + { + // Act + var response = await SendRequestAsync(r => r + .Post() + .AsTestOrganizationClientUser() + .AppendPath("events/by-ref/TestReferenceId/user-description") + .Content(new { }) + .StatusCodeShouldBeUnprocessableEntity() + ); + + using var document = await AssertProblemDetailsAsync(response, StatusCodes.Status422UnprocessableEntity, expectErrors: true); + var errors = document.RootElement.GetProperty("errors"); + Assert.True(errors.TryGetProperty("email_address", out _)); + Assert.True(errors.TryGetProperty("description", out _)); + } + + private static async Task AssertProblemDetailsAsync(HttpResponseMessage response, int statusCode, bool expectErrors = false) + { + string? mediaType = response.Content.Headers.ContentType?.MediaType; + Assert.True( + mediaType is "application/problem+json" or "application/json", + $"Expected application/problem+json or application/json but got '{mediaType}'"); + + string content = await response.Content.ReadAsStringAsync(); + var document = JsonDocument.Parse(content); + var root = document.RootElement; + + Assert.True(root.TryGetProperty("type", out _)); + Assert.True(root.TryGetProperty("title", out _)); + Assert.True(root.TryGetProperty("status", out var status)); + Assert.Equal(statusCode, status.GetInt32()); + Assert.True(root.TryGetProperty("instance", out _)); + + Assert.False(root.TryGetProperty("Status", out _)); + Assert.False(root.TryGetProperty("Title", out _)); + Assert.False(root.TryGetProperty("reference_id", out _)); + + if (expectErrors) + Assert.True(root.TryGetProperty("errors", out _)); + else + Assert.False(root.TryGetProperty("errors", out _)); + + return document; + } +} diff --git a/tests/Exceptionless.Tests/Hubs/TestWebSocket.cs b/tests/Exceptionless.Tests/Hubs/TestWebSocket.cs new file mode 100644 index 0000000000..c8343c7b3a --- /dev/null +++ b/tests/Exceptionless.Tests/Hubs/TestWebSocket.cs @@ -0,0 +1,53 @@ +using System.Net.WebSockets; +using System.Text; + +namespace Exceptionless.Tests.Hubs; + +internal sealed class TestWebSocket : WebSocket +{ + private WebSocketState _state; + + public TestWebSocket(WebSocketState state = WebSocketState.Open) + { + _state = state; + } + + public int CloseCount => _closeCount; + private int _closeCount; + public List SentMessages { get; } = []; + public override WebSocketCloseStatus? CloseStatus { get; } = WebSocketCloseStatus.NormalClosure; + public override string? CloseStatusDescription { get; } = "Closed"; + public override string? SubProtocol { get; } = null; + public override WebSocketState State => _state; + + public override void Abort() + { + _state = WebSocketState.Aborted; + } + + public override Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _closeCount); + _state = WebSocketState.Closed; + return Task.CompletedTask; + } + + public override Task CloseOutputAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken) + { + _state = WebSocketState.CloseSent; + return Task.CompletedTask; + } + + public override void Dispose() { } + + public override Task ReceiveAsync(ArraySegment buffer, CancellationToken cancellationToken) + { + return Task.FromResult(new WebSocketReceiveResult(0, WebSocketMessageType.Text, true)); + } + + public override Task SendAsync(ArraySegment buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken) + { + SentMessages.Add(Encoding.ASCII.GetString(buffer.Array!, buffer.Offset, buffer.Count)); + return Task.CompletedTask; + } +} diff --git a/tests/Exceptionless.Tests/Hubs/WebSocketConnectionManagerTests.cs b/tests/Exceptionless.Tests/Hubs/WebSocketConnectionManagerTests.cs index 01aa753b03..9018916fdc 100644 --- a/tests/Exceptionless.Tests/Hubs/WebSocketConnectionManagerTests.cs +++ b/tests/Exceptionless.Tests/Hubs/WebSocketConnectionManagerTests.cs @@ -96,52 +96,4 @@ private WebSocketConnectionManager CreateManager() var options = new AppOptions { EnableWebSockets = false }; return new WebSocketConnectionManager(options, GetService(), Log); } - - private sealed class TestWebSocket : WebSocket - { - private WebSocketState _state; - - public TestWebSocket(WebSocketState state = WebSocketState.Open) - { - _state = state; - } - - public int CloseCount { get; private set; } - public List SentMessages { get; } = []; - public override WebSocketCloseStatus? CloseStatus { get; } = WebSocketCloseStatus.NormalClosure; - public override string? CloseStatusDescription { get; } = "Closed"; - public override string? SubProtocol { get; } = null; - public override WebSocketState State => _state; - - public override void Abort() - { - _state = WebSocketState.Aborted; - } - - public override Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken) - { - CloseCount++; - _state = WebSocketState.Closed; - return Task.CompletedTask; - } - - public override Task CloseOutputAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken) - { - _state = WebSocketState.CloseSent; - return Task.CompletedTask; - } - - public override void Dispose() { } - - public override Task ReceiveAsync(ArraySegment buffer, CancellationToken cancellationToken) - { - return Task.FromResult(new WebSocketReceiveResult(0, WebSocketMessageType.Text, true)); - } - - public override Task SendAsync(ArraySegment buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken) - { - SentMessages.Add(Encoding.ASCII.GetString(buffer.Array!, buffer.Offset, buffer.Count)); - return Task.CompletedTask; - } - } } diff --git a/tests/Exceptionless.Tests/Hubs/WebSocketTests.cs b/tests/Exceptionless.Tests/Hubs/WebSocketTests.cs new file mode 100644 index 0000000000..a34a33ddc7 --- /dev/null +++ b/tests/Exceptionless.Tests/Hubs/WebSocketTests.cs @@ -0,0 +1,121 @@ +using Exceptionless.Core.Messaging.Models; +using Exceptionless.Core.Models; +using Exceptionless.Core.Utility; +using Exceptionless.Web.Hubs; +using Foundatio.Repositories.Models; +using Xunit; + +namespace Exceptionless.Tests.Hubs; + +/// +/// Tests for WebSocket behavior. Calls +/// directly so they do not depend on +/// message bus wiring or EnableWebSockets in test host configuration. +/// +public sealed class WebSocketTests : TestWithServices +{ + private readonly MessageBusBroker _broker; + private readonly IConnectionMapping _connectionMapping; + private readonly WebSocketConnectionManager _connectionManager; + + public WebSocketTests(ITestOutputHelper output) : base(output) + { + _broker = GetService(); + _connectionMapping = GetService(); + _connectionManager = GetService(); + } + + [Fact] + public async Task OnEntityChangedAsync_AuthTokenRemoved_ClosesWebSocketsAndClearsUserMapping() + { + // Arrange + const string userId = "test-user-id"; + const string organizationId = "test-organization-id"; + var socket1 = new TestWebSocket(); + var socket2 = new TestWebSocket(); + var unrelatedSocket = new TestWebSocket(); + + string connectionId1 = _connectionManager.AddWebSocket(socket1); + string connectionId2 = _connectionManager.AddWebSocket(socket2); + string unrelatedConnectionId = _connectionManager.AddWebSocket(unrelatedSocket); + + try + { + await _connectionMapping.UserIdAddAsync(userId, connectionId1); + await _connectionMapping.UserIdAddAsync(userId, connectionId2); + await _connectionMapping.GroupAddAsync(organizationId, connectionId1); + await _connectionMapping.GroupAddAsync(organizationId, connectionId2); + await _connectionMapping.GroupAddAsync(organizationId, unrelatedConnectionId); + + var entityChanged = new EntityChanged + { + Id = "test-token-id", + Type = nameof(Token), + ChangeType = ChangeType.Removed + }; + entityChanged.Data[ExtendedEntityChanged.KnownKeys.OrganizationId] = organizationId; + entityChanged.Data[ExtendedEntityChanged.KnownKeys.UserId] = userId; + entityChanged.Data[ExtendedEntityChanged.KnownKeys.IsAuthenticationToken] = true; + + // Act — call the broker directly; no message bus or EnableWebSockets dependency + await _broker.OnEntityChangedAsync(entityChanged, CancellationToken.None); + + // Assert – sockets closed and removed from manager + Assert.Null(_connectionManager.GetWebSocketById(connectionId1)); + Assert.Null(_connectionManager.GetWebSocketById(connectionId2)); + Assert.Same(unrelatedSocket, _connectionManager.GetWebSocketById(unrelatedConnectionId)); + + Assert.Equal(1, socket1.CloseCount); + Assert.Equal(1, socket2.CloseCount); + Assert.Equal(0, unrelatedSocket.CloseCount); + + // Assert – user-id mapping removed by broker + var remaining = await _connectionMapping.GetUserIdConnectionsAsync(userId); + Assert.Empty(remaining); + var organizationConnections = await _connectionMapping.GetGroupConnectionsAsync(organizationId); + Assert.DoesNotContain(connectionId1, organizationConnections); + Assert.DoesNotContain(connectionId2, organizationConnections); + Assert.Contains(unrelatedConnectionId, organizationConnections); + } + finally + { + await _connectionMapping.GroupRemoveAsync(organizationId, unrelatedConnectionId); + await _connectionManager.RemoveWebSocketAsync(unrelatedConnectionId); + } + } + + [Fact] + public async Task OnEntityChangedAsync_NonAuthTokenRemoved_DoesNotCloseWebSockets() + { + // Arrange + const string userId = "test-user-id-2"; + var socket = new TestWebSocket(); + string connectionId = _connectionManager.AddWebSocket(socket); + + try + { + await _connectionMapping.UserIdAddAsync(userId, connectionId); + + var entityChanged = new EntityChanged + { + Id = "test-api-token-id", + Type = nameof(Token), + ChangeType = ChangeType.Removed + }; + entityChanged.Data[ExtendedEntityChanged.KnownKeys.UserId] = userId; + // IsAuthenticationToken intentionally omitted (defaults false) + + // Act + await _broker.OnEntityChangedAsync(entityChanged, CancellationToken.None); + + // Assert – socket should NOT be closed for a non-auth token removal + Assert.Equal(0, socket.CloseCount); + Assert.Same(socket, _connectionManager.GetWebSocketById(connectionId)); + } + finally + { + await _connectionMapping.UserIdRemoveAsync(userId, connectionId); + await _connectionManager.RemoveWebSocketAsync(connectionId); + } + } +} diff --git a/tests/Exceptionless.Tests/Utility/ConnectionMappingTests.cs b/tests/Exceptionless.Tests/Utility/ConnectionMappingTests.cs new file mode 100644 index 0000000000..c0899a827e --- /dev/null +++ b/tests/Exceptionless.Tests/Utility/ConnectionMappingTests.cs @@ -0,0 +1,283 @@ +using Exceptionless.Core.Utility; +using Foundatio.Xunit; +using Xunit; + +namespace Exceptionless.Tests.Utility; + +public sealed class ConnectionMappingTests(ITestOutputHelper output) : TestWithLoggingBase(output) +{ + [Fact] + public async Task AddAsync_NewKey_CanRetrieveConnection() + { + // Arrange + var mapping = new ConnectionMapping(); + + // Act + await mapping.AddAsync("user1", "conn1"); + + // Assert + var connections = await mapping.GetConnectionsAsync("user1"); + Assert.Contains("conn1", connections); + Assert.Equal(1, await mapping.GetConnectionCountAsync("user1")); + } + + [Fact] + public async Task AddAsync_ExistingKey_AccumulatesConnections() + { + // Arrange + var mapping = new ConnectionMapping(); + await mapping.AddAsync("user1", "conn1"); + + // Act + await mapping.AddAsync("user1", "conn2"); + + // Assert + var connections = await mapping.GetConnectionsAsync("user1"); + Assert.Equal(2, connections.Count); + Assert.Contains("conn1", connections); + Assert.Contains("conn2", connections); + Assert.Equal(1, mapping.TrackedKeyCount); + } + + [Fact] + public async Task AddAsync_NullKey_DoesNotTrackConnection() + { + // Arrange + var mapping = new ConnectionMapping(); + + // Act + await mapping.AddAsync(null!, "conn1"); + + // Assert + Assert.Empty(await mapping.GetConnectionsAsync(null!)); + Assert.Equal(0, await mapping.GetConnectionCountAsync(null!)); + Assert.Equal(0, mapping.TrackedKeyCount); + } + + [Fact] + public async Task RemoveAsync_LastConnection_RemovesTrackedKey() + { + // Arrange + var mapping = new ConnectionMapping(); + await mapping.AddAsync("user1", "conn1"); + + // Act + await mapping.RemoveAsync("user1", "conn1"); + + // Assert + var connections = await mapping.GetConnectionsAsync("user1"); + Assert.Empty(connections); + Assert.Equal(0, await mapping.GetConnectionCountAsync("user1")); + Assert.Equal(0, mapping.TrackedKeyCount); + } + + [Fact] + public async Task RemoveAsync_OneOfMultipleConnections_LeavesRemainder() + { + // Arrange + var mapping = new ConnectionMapping(); + await mapping.AddAsync("user1", "conn1"); + await mapping.AddAsync("user1", "conn2"); + + // Act + await mapping.RemoveAsync("user1", "conn1"); + + // Assert + var connections = await mapping.GetConnectionsAsync("user1"); + Assert.DoesNotContain("conn1", connections); + Assert.Contains("conn2", connections); + Assert.Single(connections); + Assert.Equal(1, mapping.TrackedKeyCount); + } + + [Fact] + public async Task RemoveAsync_UnknownKey_DoesNotTrackEmptyKey() + { + // Arrange + var mapping = new ConnectionMapping(); + + // Act + await mapping.RemoveAsync("nonexistent", "conn1"); + + // Assert + Assert.Empty(await mapping.GetConnectionsAsync("nonexistent")); + Assert.Equal(0, await mapping.GetConnectionCountAsync("nonexistent")); + Assert.Equal(0, mapping.TrackedKeyCount); + } + + [Fact] + public async Task RemoveAsync_UnknownConnection_DoesNotAffectOthers() + { + // Arrange + var mapping = new ConnectionMapping(); + await mapping.AddAsync("user1", "conn1"); + + // Act + await mapping.RemoveAsync("user1", "conn-missing"); + + // Assert + var connections = await mapping.GetConnectionsAsync("user1"); + Assert.Contains("conn1", connections); + Assert.Single(connections); + Assert.Equal(1, mapping.TrackedKeyCount); + } + + [Fact] + public async Task RemoveAsync_NullKey_DoesNotTrackEmptyKey() + { + // Arrange + var mapping = new ConnectionMapping(); + + // Act + await mapping.RemoveAsync(null!, "conn1"); + + // Assert + Assert.Empty(await mapping.GetConnectionsAsync(null!)); + Assert.Equal(0, await mapping.GetConnectionCountAsync(null!)); + Assert.Equal(0, mapping.TrackedKeyCount); + } + + [Fact] + public async Task AddAsync_AfterLastConnectionRemoved_RecreatesTrackedKey() + { + // Arrange + var mapping = new ConnectionMapping(); + await mapping.AddAsync("user1", "conn1"); + await mapping.RemoveAsync("user1", "conn1"); + + // Act + await mapping.AddAsync("user1", "conn2"); + + // Assert + var connections = await mapping.GetConnectionsAsync("user1"); + Assert.DoesNotContain("conn1", connections); + Assert.Contains("conn2", connections); + Assert.Single(connections); + Assert.Equal(1, mapping.TrackedKeyCount); + } + + [Fact] + public async Task AddAsync_ConcurrentWithRemovingLastConnection_PreservesAddedConnection() + { + // Arrange + var mapping = new ConnectionMapping(); + const string key = "user1"; + await mapping.AddAsync(key, "conn1"); + + // Act + await Task.WhenAll( + Task.Run(() => mapping.RemoveAsync(key, "conn1"), TestContext.Current.CancellationToken), + Task.Run(() => mapping.AddAsync(key, "conn2"), TestContext.Current.CancellationToken)); + + // Assert + var connections = await mapping.GetConnectionsAsync(key); + Assert.DoesNotContain("conn1", connections); + Assert.Contains("conn2", connections); + Assert.Single(connections); + Assert.Equal(1, mapping.TrackedKeyCount); + } + + [Fact] + public async Task RemoveAsync_AllConnectionsRemovedConcurrently_RemovesTrackedKey() + { + // Arrange + var mapping = new ConnectionMapping(); + const string key = "user1"; + await mapping.AddAsync(key, "conn1"); + await mapping.AddAsync(key, "conn2"); + + // Act + await Task.WhenAll( + Task.Run(() => mapping.RemoveAsync(key, "conn1"), TestContext.Current.CancellationToken), + Task.Run(() => mapping.RemoveAsync(key, "conn2"), TestContext.Current.CancellationToken)); + + // Assert + Assert.Empty(await mapping.GetConnectionsAsync(key)); + Assert.Equal(0, await mapping.GetConnectionCountAsync(key)); + Assert.Equal(0, mapping.TrackedKeyCount); + } + + [Fact] + public async Task AddAsyncAndRemoveAsync_ConcurrentSameKey_CleansTrackedKey() + { + // Arrange + var mapping = new ConnectionMapping(); + const string key = "user1"; + string[] connectionIds = Enumerable.Range(0, 512).Select(i => $"conn{i}").ToArray(); + + // Act + await Task.WhenAll(connectionIds.Select(connectionId => Task.Run(async () => + { + await mapping.AddAsync(key, connectionId); + await Task.Yield(); + await mapping.RemoveAsync(key, connectionId); + }))); + + // Assert + Assert.Empty(await mapping.GetConnectionsAsync(key)); + Assert.Equal(0, await mapping.GetConnectionCountAsync(key)); + Assert.Equal(0, mapping.TrackedKeyCount); + } + + [Fact] + public async Task GetConnectionsAsync_NullKey_ReturnsEmpty() + { + // Arrange + var mapping = new ConnectionMapping(); + + // Act + var connections = await mapping.GetConnectionsAsync(null!); + + // Assert + Assert.Empty(connections); + } + + [Fact] + public async Task GetConnectionsAsync_ReturnsSnapshot_NotLiveReference() + { + // Arrange + var mapping = new ConnectionMapping(); + await mapping.AddAsync("user1", "conn1"); + + // Act + var snapshot = await mapping.GetConnectionsAsync("user1"); + await mapping.AddAsync("user1", "conn2"); + + // Assert – snapshot is not affected by later mutations + Assert.Single(snapshot); + Assert.Equal(2, (await mapping.GetConnectionsAsync("user1")).Count); + } + + [Fact] + public async Task GroupExtensions_AddAndRemove_WorkCorrectly() + { + // Arrange + var mapping = new ConnectionMapping(); + await mapping.GroupAddAsync("organization1", "conn1"); + await mapping.GroupAddAsync("organization1", "conn2"); + + // Act + await mapping.GroupRemoveAsync("organization1", "conn1"); + + // Assert + var connections = await mapping.GetGroupConnectionsAsync("organization1"); + Assert.DoesNotContain("conn1", connections); + Assert.Contains("conn2", connections); + Assert.Equal(1, await mapping.GetGroupConnectionCountAsync("organization1")); + } + + [Fact] + public async Task UserIdExtensions_AddAndRemove_WorkCorrectly() + { + // Arrange + var mapping = new ConnectionMapping(); + await mapping.UserIdAddAsync("user1", "conn1"); + + // Act + await mapping.UserIdRemoveAsync("user1", "conn1"); + + // Assert + var connections = await mapping.GetUserIdConnectionsAsync("user1"); + Assert.Empty(connections); + } +} diff --git a/tests/Exceptionless.Tests/Utility/Handlers/OverageMiddlewareTests.cs b/tests/Exceptionless.Tests/Utility/Handlers/OverageMiddlewareTests.cs new file mode 100644 index 0000000000..b36c0d122e --- /dev/null +++ b/tests/Exceptionless.Tests/Utility/Handlers/OverageMiddlewareTests.cs @@ -0,0 +1,252 @@ +using System.Security.Claims; +using Exceptionless.Core; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Utility; +using Exceptionless.Web.Utility; +using Xunit; + +namespace Exceptionless.Tests.Utility.Handlers; + +public sealed class OverageMiddlewareTests : IntegrationTestsBase +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IUserRepository _userRepository; + private readonly UsageService _usageService; + + public OverageMiddlewareTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _organizationRepository = GetService(); + _userRepository = GetService(); + _usageService = GetService(); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + await GetService().CreateDataAsync(); + } + + [Fact] + public async Task Invoke_SuspendedOrganizationUserRequest_ReturnsPaymentRequired() + { + // Arrange + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(user); + + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + organization.IsSuspended = true; + organization.SuspensionCode = SuspensionCode.Billing; + organization.SuspensionDate = DateTime.UtcNow; + organization.SuspendedByUserId = user.Id; + await _organizationRepository.SaveAsync(organization); + + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + + return Task.CompletedTask; + }); + var context = CreateEventPostContext(new ClaimsPrincipal(user.ToIdentity()), 128); + + // Act + await middleware.Invoke(context); + + // Assert + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status402PaymentRequired, context.Response.StatusCode); + } + + [Fact] + public async Task Invoke_MissingContentLength_CallsNext() + { + // Arrange + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + + return Task.CompletedTask; + }); + var context = CreateEventPostContext(CreateTokenPrincipal(SampleDataService.TEST_API_KEY, SampleDataService.TEST_ORG_ID, SampleDataService.TEST_PROJECT_ID)); + + // Act + await middleware.Invoke(context); + + // Assert + Assert.True(nextCalled); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + + [Fact] + public async Task Invoke_ZeroContentLength_ReturnsLengthRequired() + { + // Arrange + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + + return Task.CompletedTask; + }); + var context = CreateEventPostContext( + CreateTokenPrincipal(SampleDataService.TEST_API_KEY, SampleDataService.TEST_ORG_ID, SampleDataService.TEST_PROJECT_ID), + 0); + + // Act + await middleware.Invoke(context); + + // Assert + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status411LengthRequired, context.Response.StatusCode); + } + + [Fact] + public async Task Invoke_PayloadTooLarge_ReturnsRequestEntityTooLarge() + { + // Arrange + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + + return Task.CompletedTask; + }); + var context = CreateEventPostContext( + CreateTokenPrincipal(SampleDataService.TEST_API_KEY, SampleDataService.TEST_ORG_ID, SampleDataService.TEST_PROJECT_ID), + GetService().MaximumEventPostSize + 1); + + // Act + await middleware.Invoke(context); + + // Assert + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status413RequestEntityTooLarge, context.Response.StatusCode); + } + + [Fact] + public async Task Invoke_OrganizationOverUsageLimit_ReturnsPaymentRequired() + { + // Arrange + await _usageService.IncrementTotalAsync(SampleDataService.FREE_ORG_ID, SampleDataService.FREE_PROJECT_ID, 1_000_000); + + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + + return Task.CompletedTask; + }); + var context = CreateEventPostContext( + CreateTokenPrincipal(SampleDataService.FREE_API_KEY, SampleDataService.FREE_ORG_ID, SampleDataService.FREE_PROJECT_ID), + 128); + + // Act + await middleware.Invoke(context); + + // Assert + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status402PaymentRequired, context.Response.StatusCode); + } + + [Fact] + public async Task Invoke_NonEventPostRequest_CallsNext() + { + // Arrange + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + + return Task.CompletedTask; + }); + var context = CreateContext(HttpMethods.Get, "/api/v2/projects"); + + // Act + await middleware.Invoke(context); + + // Assert + Assert.True(nextCalled); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + + [Fact] + public async Task Invoke_EventSubmissionDisabled_ReturnsServiceUnavailable() + { + // Arrange + var appOptions = GetService(); + appOptions.EventSubmissionDisabled = true; + + try + { + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + + return Task.CompletedTask; + }); + var context = CreateEventPostContext( + CreateTokenPrincipal(SampleDataService.TEST_API_KEY, SampleDataService.TEST_ORG_ID, SampleDataService.TEST_PROJECT_ID), + 128); + + // Act + await middleware.Invoke(context); + + // Assert + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode); + } + finally + { + appOptions.EventSubmissionDisabled = false; + } + } + + private OverageMiddleware CreateMiddleware(RequestDelegate next) + { + return new OverageMiddleware( + next, + _usageService, + _organizationRepository, + GetService(), + GetService>()); + } + + private static ClaimsPrincipal CreateTokenPrincipal(string tokenId, string organizationId, string projectId) + { + var token = new Token + { + Id = tokenId, + Type = TokenType.Access, + OrganizationId = organizationId, + ProjectId = projectId + }; + + return new ClaimsPrincipal(token.ToIdentity()); + } + + private static DefaultHttpContext CreateEventPostContext(ClaimsPrincipal user, long? contentLength = null) + { + var context = CreateContext(HttpMethods.Post, "/api/v2/events"); + context.User = user; + if (contentLength.HasValue) + context.Request.Headers.ContentLength = contentLength.Value; + + return context; + } + + private static DefaultHttpContext CreateContext(string method, string path) + { + var context = new DefaultHttpContext(); + context.Request.Method = method; + context.Request.Path = path; + context.Response.Body = new MemoryStream(); + return context; + } +} diff --git a/tests/Exceptionless.Tests/Utility/Handlers/ThrottlingMiddlewareTests.cs b/tests/Exceptionless.Tests/Utility/Handlers/ThrottlingMiddlewareTests.cs index 3d333056b9..294d33c56a 100644 --- a/tests/Exceptionless.Tests/Utility/Handlers/ThrottlingMiddlewareTests.cs +++ b/tests/Exceptionless.Tests/Utility/Handlers/ThrottlingMiddlewareTests.cs @@ -80,6 +80,48 @@ public async Task Invoke_DoesNotThrottleKnownConfigurationAndHeartbeatRoutes() Assert.Equal(2, nextCallCount); } + [Fact] + public async Task Invoke_V1ProjectConfigurationPath_DoesNotThrottleRequest() + { + // Arrange + var cache = GetService(); + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + return Task.CompletedTask; + }, cache, maxRequests: 0); + var context = CreateContext("/api/v1/project/config"); + + // Act + await middleware.Invoke(context); + + // Assert + Assert.True(nextCalled); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + + [Fact] + public async Task Invoke_WebSocketPath_DoesNotThrottleRequest() + { + // Arrange + var cache = GetService(); + bool nextCalled = false; + var middleware = CreateMiddleware(context => + { + nextCalled = true; + return Task.CompletedTask; + }, cache, maxRequests: 0); + var context = CreateContext("/api/v2/push"); + + // Act + await middleware.Invoke(context); + + // Assert + Assert.True(nextCalled); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + private ThrottlingMiddleware CreateMiddleware(RequestDelegate next, ICacheClient cache, long maxRequests) { return new ThrottlingMiddleware(next, cache, new ThrottlingOptions