diff --git a/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs index a45cc56a4e..aede1d8f3b 100644 --- a/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs @@ -31,6 +31,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder .RequireAuthorization(AuthorizationRoles.EventsReadPolicy) .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) .WithSummary("Count") .WithMetadata(new EndpointDocumentation { ParameterDescriptions = new() { @@ -42,6 +43,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder }, ResponseDescriptions = new() { ["400"] = "Invalid filter.", + ["426"] = "Premium search features require an upgraded plan.", } }); @@ -50,6 +52,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder .RequireAuthorization(AuthorizationRoles.EventsReadPolicy) .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) .WithSummary("Count by organization") .WithMetadata(new EndpointDocumentation { ParameterDescriptions = new() { @@ -62,6 +65,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder }, ResponseDescriptions = new() { ["400"] = "Invalid filter.", + ["426"] = "The organization is suspended or premium search features require an upgraded plan.", } }); @@ -70,6 +74,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder .RequireAuthorization(AuthorizationRoles.EventsReadPolicy) .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) .WithSummary("Count by project") .WithMetadata(new EndpointDocumentation { ParameterDescriptions = new() { @@ -82,6 +87,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder }, ResponseDescriptions = new() { ["400"] = "Invalid filter.", + ["426"] = "The organization is suspended or premium search features require an upgraded plan.", } }); @@ -132,7 +138,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder }, ResponseDescriptions = new() { ["400"] = "Invalid filter.", - ["426"] = "Unable to view event occurrences for the suspended organization.", + ["426"] = "Premium search features require an upgraded plan.", } }); @@ -162,7 +168,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder ResponseDescriptions = new() { ["400"] = "Invalid filter.", ["404"] = "The organization could not be found.", - ["426"] = "Unable to view event occurrences for the suspended organization.", + ["426"] = "The organization is suspended or premium search features require an upgraded plan.", } }); @@ -192,7 +198,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder ResponseDescriptions = new() { ["400"] = "Invalid filter.", ["404"] = "The project could not be found.", - ["426"] = "Unable to view event occurrences for the suspended organization.", + ["426"] = "The organization is suspended or premium search features require an upgraded plan.", } }); @@ -222,7 +228,7 @@ public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder ResponseDescriptions = new() { ["400"] = "Invalid filter.", ["404"] = "The stack could not be found.", - ["426"] = "Unable to view event occurrences for the suspended organization.", + ["426"] = "The organization is suspended or premium search features require an upgraded plan.", } }); diff --git a/src/Exceptionless.Web/Api/Endpoints/StackEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/StackEndpoints.cs index c97aaf48ca..3af15e02d7 100644 --- a/src/Exceptionless.Web/Api/Endpoints/StackEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/StackEndpoints.cs @@ -237,6 +237,7 @@ public static IEndpointRouteBuilder MapStackEndpoints(this IEndpointRouteBuilder .RequireAuthorization(AuthorizationRoles.StacksReadPolicy) .Produces>() .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status426UpgradeRequired) .WithSummary("Get all") .WithMetadata(new EndpointDocumentation { ParameterDescriptions = new() { @@ -250,6 +251,7 @@ public static IEndpointRouteBuilder MapStackEndpoints(this IEndpointRouteBuilder }, ResponseDescriptions = new() { ["400"] = "Invalid filter.", + ["426"] = "Premium search features require an upgraded plan.", } }); @@ -276,7 +278,7 @@ public static IEndpointRouteBuilder MapStackEndpoints(this IEndpointRouteBuilder ResponseDescriptions = new() { ["400"] = "Invalid filter.", ["404"] = "The organization could not be found.", - ["426"] = "Unable to view stack occurrences for the suspended organization.", + ["426"] = "The organization is suspended or premium search features require an upgraded plan.", } }); @@ -303,7 +305,7 @@ public static IEndpointRouteBuilder MapStackEndpoints(this IEndpointRouteBuilder ResponseDescriptions = new() { ["400"] = "Invalid filter.", ["404"] = "The organization could not be found.", - ["426"] = "Unable to view stack occurrences for the suspended organization.", + ["426"] = "The organization is suspended or premium search features require an upgraded plan.", } }); diff --git a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs index eb77c82c92..5165b24497 100644 --- a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs @@ -45,6 +45,7 @@ public class EventHandler( ICacheClient cacheClient, ITextSerializer serializer, PersistentEventQueryValidator validator, + StackQueryValidator stackValidator, AppOptions appOptions, UsageService usageService, TimeProvider timeProvider, @@ -687,7 +688,9 @@ public async Task> Handle(DeleteEvents message) private async Task> CountInternalAsync(AppFilter sf, TimeInfo ti, HttpContext httpContext, string? filter = null, string? aggregations = null, string? mode = null) { - var pr = await validator.ValidateQueryAsync(filter); + var pr = IsStackMode(mode) + ? await stackValidator.ValidateQueryAsync(filter) + : await validator.ValidateQueryAsync(filter); if (!pr.IsValid) return Result.BadRequest(pr.Message ?? "Invalid filter."); @@ -696,6 +699,8 @@ private async Task> CountInternalAsync(AppFilter sf, TimeInf return Result.BadRequest(far.Message ?? "Invalid aggregations."); sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || far.UsesPremiumFeatures; + if (ApiValidation.IsPremiumFeatureQueryBlocked(sf)) + return PlanLimitResult("Please upgrade your plan to use premium search features."); if (mode == "stack_new") filter = AddFirstOccurrenceFilter(ti.Range, filter); @@ -749,11 +754,15 @@ private async Task>> GetInternalAsync(AppFilter sf, T if (skip > Pagination.MaximumSkip) return new PagedResult(Array.Empty(), false); - var pr = await validator.ValidateQueryAsync(filter); + var pr = IsStackMode(mode) + ? await stackValidator.ValidateQueryAsync(filter) + : await validator.ValidateQueryAsync(filter); if (!pr.IsValid) return Result.BadRequest(pr.Message ?? "Invalid filter."); sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || usesPremiumFeatures; + if (ApiValidation.IsPremiumFeatureQueryBlocked(sf)) + return PlanLimitResult>("Please upgrade your plan to use premium search features."); try { @@ -874,6 +883,11 @@ private static string AddFirstOccurrenceFilter(DateTimeRange timeRange, string? return sb.ToString(); } + private static bool IsStackMode(string? mode) + { + return mode is "stack_recent" or "stack_frequent" or "stack_new" or "stack_users"; + } + private Task> GetEventsInternalAsync(AppFilter sf, TimeInfo ti, string? filter, string? sort, int? page, int limit, string? before, string? after, bool includeTotal, HttpRequest? request = null) { if (String.IsNullOrEmpty(sort)) diff --git a/src/Exceptionless.Web/Api/Handlers/StackHandler.cs b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs index b61206d1d1..896831cc2c 100644 --- a/src/Exceptionless.Web/Api/Handlers/StackHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs @@ -408,6 +408,8 @@ private async Task>> GetInternalAsync(AppFilter sf, T return Result.BadRequest(pr.Message ?? "Invalid filter."); sf.UsesPremiumFeatures = pr.UsesPremiumFeatures; + if (ApiValidation.IsPremiumFeatureQueryBlocked(sf)) + return PlanLimitResult>("Please upgrade your plan to use premium search features."); try { diff --git a/src/Exceptionless.Web/Api/Infrastructure/ApiValidation.cs b/src/Exceptionless.Web/Api/Infrastructure/ApiValidation.cs index 121b047629..d4864f9fce 100644 --- a/src/Exceptionless.Web/Api/Infrastructure/ApiValidation.cs +++ b/src/Exceptionless.Web/Api/Infrastructure/ApiValidation.cs @@ -1,9 +1,17 @@ +using Exceptionless.Core.Repositories.Queries; using Microsoft.AspNetCore.Http.Features; namespace Exceptionless.Web.Api.Infrastructure; public static class ApiValidation { + public static bool IsPremiumFeatureQueryBlocked(AppFilter filter) + { + return filter.UsesPremiumFeatures + && filter.Organizations.Count > 0 + && filter.Organizations.All(organization => !organization.HasPremiumFeatures); + } + public static IResult MissingRequestBody() { return global::Microsoft.AspNetCore.Http.Results.ValidationProblem( diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts index 9a57dd1a2a..a50f578bcc 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.svelte.ts @@ -113,7 +113,7 @@ export interface GetOrganizationCountRequest { params?: { aggregations?: string; filter?: string; - mode?: 'stack_new'; + mode?: GetEventsMode; offset?: string; time?: string; }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.test.ts index a94b4a70aa..d66da04c85 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/api.test.ts @@ -2,11 +2,54 @@ import { ChangeType } from '$features/websockets/models'; import { QueryClient } from '@tanstack/svelte-query'; import { describe, expect, it, vi } from 'vitest'; +const fetchClientMocks = vi.hoisted(() => ({ + getJSON: vi.fn() +})); + vi.mock('$features/auth/index.svelte', () => ({ accessToken: { current: 'test-token' } })); -import { invalidatePersistentEventQueries, queryKeys } from './api.svelte'; +vi.mock('@exceptionless/fetchclient', () => ({ + useFetchClient: () => ({ getJSON: fetchClientMocks.getJSON }) +})); + +vi.mock('@tanstack/svelte-query', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createQuery: vi.fn((factory) => factory()), + useQueryClient: vi.fn(() => new actual.QueryClient()) + }; +}); + +import { getOrganizationCountQuery, invalidatePersistentEventQueries, queryKeys } from './api.svelte'; + +describe('getOrganizationCountQuery', () => { + it('forwards stack mode with a stack-only filter to the count request', async () => { + // Arrange + fetchClientMocks.getJSON.mockResolvedValue({ data: { aggregations: {}, total: 0 } }); + const query = getOrganizationCountQuery({ + params: { + filter: 'critical:false', + mode: 'stack_frequent' + }, + route: { organizationId: 'organization-id' } + }) as unknown as { queryFn: (context: { signal: AbortSignal }) => Promise }; + + // Act + await query.queryFn({ signal: new AbortController().signal }); + + // Assert + expect(fetchClientMocks.getJSON).toHaveBeenCalledWith('/organizations/organization-id/events/count', { + params: expect.objectContaining({ + filter: 'critical:false', + mode: 'stack_frequent' + }), + signal: expect.any(AbortSignal) + }); + }); +}); describe('invalidatePersistentEventQueries', () => { it('does not invalidate nested count aggregation queries for event updates', async () => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.test.ts new file mode 100644 index 0000000000..310addec30 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { filterUsesPremiumFeatures } from './premium-filter'; + +describe('filterUsesPremiumFeatures', () => { + it.each([undefined, null, '', 'status:open', '(status:open OR status:regressed)', 'reference:ABC123'])('allows free event filters: %s', (filter) => { + expect(filterUsesPremiumFeatures(filter, 'event')).toBe(false); + }); + + it.each(['tags:important', 'data.@user.identity:blake', 'message:"out of memory"', '-tags:important', '+tags:important'])( + 'detects premium event filters: %s', + (filter) => { + expect(filterUsesPremiumFeatures(filter, 'event')).toBe(true); + } + ); + + it.each(['first_occurrence:[now-1d TO now]', 'last:now', 'occurrences_are_critical:true', 'critical:false', 'project:ABC123'])( + 'allows free stack filters: %s', + (filter) => { + expect(filterUsesPremiumFeatures(filter, 'stack')).toBe(false); + } + ); + + it.each(['title:"out of memory"', 'reference:ABC123', 'stack:ABC123', 'tags:important'])('detects premium stack filters: %s', (filter) => { + expect(filterUsesPremiumFeatures(filter, 'stack')).toBe(true); + }); + + it('detects a premium field after a free field', () => { + expect(filterUsesPremiumFeatures('status:open AND tags:important', 'event')).toBe(true); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts index ccb9fbd3a4..b361b127de 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/premium-filter.ts @@ -1,33 +1,36 @@ -/** - * Free query fields that don't require a premium plan. - * Any field referenced in a filter that is NOT in this set requires premium features. - * Must be kept in sync with PersistentEventQueryValidator._freeQueryFields on the backend. - */ -const FREE_QUERY_FIELDS = new Set([ - 'date', - 'organization', - 'organization_id', - 'project', - 'project_id', - 'reference', - 'reference_id', - 'stack', - 'stack_id', - 'status', - 'type' -]); +export type SearchResource = 'event' | 'stack'; + +// These mirror the backend query validators so the upgrade notification is shown +// before a restricted request fails. The API remains the enforcement boundary. +const FREE_QUERY_FIELDS: Record> = { + event: new Set(['date', 'organization', 'organization_id', 'project', 'project_id', 'reference', 'reference_id', 'stack', 'stack_id', 'status', 'type']), + stack: new Set([ + 'critical', + 'first', + 'first_occurrence', + 'last', + 'last_occurrence', + 'occurrences_are_critical', + 'organization', + 'organization_id', + 'project', + 'project_id', + 'status', + 'type' + ]) +}; /** * Returns true if the filter string references fields that require a premium plan. * Uses client-side field detection to avoid an extra API call. */ -export function filterUsesPremiumFeatures(filter: null | string | undefined): boolean { +export function filterUsesPremiumFeatures(filter: null | string | undefined, resource: SearchResource): boolean { if (!filter) { return false; } const fields = extractFilterFields(filter); - return fields.some((field) => !FREE_QUERY_FIELDS.has(field.toLowerCase())); + return fields.some((field) => !FREE_QUERY_FIELDS[resource].has(field.toLowerCase())); } /** @@ -35,7 +38,7 @@ export function filterUsesPremiumFeatures(filter: null | string | undefined): bo * Matches patterns like `field:value` or `field:(value1 OR value2)`. */ function extractFilterFields(filter: string): string[] { - const fieldPattern = /(?:^|\s|[(!])(\w[\w.]*):/g; + const fieldPattern = /(?:^|\s|[(!])[-+]?(\w[\w.@]*):/g; const fields: string[] = []; let match: null | RegExpExecArray; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 9deb9cb990..afb52ba4bc 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -11,7 +11,6 @@ import { accessToken, gotoLogin } from '$features/auth/index.svelte'; import { UpgradeRequiredDialog } from '$features/billing'; import { invalidatePersistentEventQueries } from '$features/events/api.svelte'; - import { filterUsesPremiumFeatures } from '$features/events/premium-filter'; import { buildIntercomBootOptions, IntercomShell } from '$features/intercom'; import { shouldLoadIntercomOrganization } from '$features/intercom/config'; import Notifications from '$features/notifications/components/notifications.svelte'; @@ -51,7 +50,7 @@ let { children }: Props = $props(); let isAuthenticated = $derived(!!accessToken.current); - let requiresPremium = $derived(premiumPage.requiresPremium || filterUsesPremiumFeatures(page.url.searchParams.get('filter'))); + let requiresPremium = $derived(premiumPage.requiresPremium); const sidebar = useSidebar(); let isCommandOpen = $state(false); let commandResetKey = $state(0); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index 768c98165e..269a30bc47 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -43,7 +43,9 @@ import EventsBulkActionsDropdownMenu from '$features/events/components/table/events-bulk-actions-dropdown-menu.svelte'; import EventsDataTable from '$features/events/components/table/events-data-table.svelte'; import { defaultEventColumnVisibility, getColumns } from '$features/events/components/table/options.svelte'; + import { filterUsesPremiumFeatures } from '$features/events/premium-filter'; import { organization } from '$features/organizations/context.svelte'; + import { premiumPage } from '$features/organizations/premium-page.svelte'; import SavedViewPicker from '$features/saved-views/components/saved-view-picker.svelte'; import { useSavedViews } from '$features/saved-views/use-saved-views.svelte'; import * as agg from '$features/shared/api/aggregations'; @@ -652,6 +654,10 @@ } }); + $effect(() => { + premiumPage.current = filterUsesPremiumFeatures(eventsQueryParameters.filter, 'event') ? 'search' : undefined; + }); + const client = useFetchClient(); const clientStatus = useFetchClientStatus(client); let clientResponse = $state[]>>(); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte index cbb73914c0..b0e977d722 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -39,7 +39,9 @@ import OrganizationDefaultsFacetedFilterBuilder from '$features/events/components/filters/organization-defaults-faceted-filter-builder.svelte'; import EventsDataTable from '$features/events/components/table/events-data-table.svelte'; import { getColumns } from '$features/events/components/table/options.svelte'; + import { filterUsesPremiumFeatures } from '$features/events/premium-filter'; import { organization } from '$features/organizations/context.svelte'; + import { premiumPage } from '$features/organizations/premium-page.svelte'; import SavedViewPicker from '$features/saved-views/components/saved-view-picker.svelte'; import { useSavedViews } from '$features/saved-views/use-saved-views.svelte'; import * as agg from '$features/shared/api/aggregations'; @@ -620,6 +622,10 @@ } }); + $effect(() => { + premiumPage.current = filterUsesPremiumFeatures(eventsQueryParameters.filter, 'stack') ? 'search' : undefined; + }); + const client = useFetchClient(); const clientStatus = useFetchClientStatus(client); let clientResponse = $state[]>>(); @@ -715,6 +721,9 @@ get filter() { return eventsQueryParameters.filter; }, + get mode() { + return eventsQueryParameters.mode; + }, get time() { return eventsQueryParameters.time; } diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 68ec2364ce..69b73d6c95 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -7255,6 +7255,16 @@ } } } + }, + "426": { + "description": "Premium search features require an upgraded plan.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -7372,7 +7382,7 @@ } }, "426": { - "description": "Unable to view stack occurrences for the suspended organization.", + "description": "The organization is suspended or premium search features require an upgraded plan.", "content": { "application/problem\u002Bjson": { "schema": { @@ -7497,7 +7507,7 @@ } }, "426": { - "description": "Unable to view stack occurrences for the suspended organization.", + "description": "The organization is suspended or premium search features require an upgraded plan.", "content": { "application/problem\u002Bjson": { "schema": { @@ -7893,6 +7903,16 @@ } } } + }, + "426": { + "description": "Premium search features require an upgraded plan.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -7975,6 +7995,16 @@ } } } + }, + "426": { + "description": "The organization is suspended or premium search features require an upgraded plan.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -8057,6 +8087,16 @@ } } } + }, + "426": { + "description": "The organization is suspended or premium search features require an upgraded plan.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } } } @@ -8264,7 +8304,7 @@ } }, "426": { - "description": "Unable to view event occurrences for the suspended organization.", + "description": "Premium search features require an upgraded plan.", "content": { "application/problem\u002Bjson": { "schema": { @@ -8474,7 +8514,7 @@ } }, "426": { - "description": "Unable to view event occurrences for the suspended organization.", + "description": "The organization is suspended or premium search features require an upgraded plan.", "content": { "application/problem\u002Bjson": { "schema": { @@ -8622,7 +8662,7 @@ } }, "426": { - "description": "Unable to view event occurrences for the suspended organization.", + "description": "The organization is suspended or premium search features require an upgraded plan.", "content": { "application/problem\u002Bjson": { "schema": { @@ -8842,7 +8882,7 @@ } }, "426": { - "description": "Unable to view event occurrences for the suspended organization.", + "description": "The organization is suspended or premium search features require an upgraded plan.", "content": { "application/problem\u002Bjson": { "schema": { diff --git a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs index 00be49ac24..2c8a196431 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs @@ -862,6 +862,77 @@ public async Task CanGetFreeProjectLevelMostFrequentStackMode() Assert.Equal(2, results.Count); } + [Fact] + public async Task Handle_GetEventCountByProjectWithPremiumFilterOnFreeOrganization_ReturnsUpgradeRequired() + { + // Arrange + await CreateDataAsync(d => d.Event().FreeProject().Tag("premium-tag")); + + // Act & Assert + await SendRequestAsync(r => r + .AsFreeOrganizationUser() + .AppendPaths("projects", SampleDataService.FREE_PROJECT_ID, "events", "count") + .QueryString("filter", "tags:premium-tag") + .StatusCodeShouldBeUpgradeRequired() + ); + } + + [Theory] + [InlineData("critical:false")] + [InlineData("first_occurrence:[now-1d TO now]")] + public async Task Handle_GetEventCountByOrganizationInStackModeWithFreeStackFilter_ReturnsOk(string filter) + { + // Arrange + await CreateDataAsync(d => d.Event().FreeProject()); + + // Act + var result = await SendRequestAsAsync(r => r + .AsFreeOrganizationUser() + .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "events", "count") + .QueryString("filter", filter) + .QueryString("mode", "stack_frequent") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public async Task Handle_GetEventsByProjectWithPremiumFilterOnFreeOrganization_ReturnsUpgradeRequired() + { + // Arrange + await CreateDataAsync(d => d.Event().FreeProject().Tag("premium-tag")); + + // Act & Assert + await SendRequestAsync(r => r + .AsFreeOrganizationUser() + .AppendPaths("projects", SampleDataService.FREE_PROJECT_ID, "events") + .QueryString("filter", "tags:premium-tag") + .StatusCodeShouldBeUpgradeRequired() + ); + } + + [Fact] + public async Task Handle_GetEventsByProjectInStackModeWithFreeStackFilter_ReturnsOk() + { + // Arrange + await CreateDataAsync(d => d.Event().FreeProject()); + + // Act + var results = await SendRequestAsAsync>(r => r + .AsFreeOrganizationUser() + .AppendPaths("projects", SampleDataService.FREE_PROJECT_ID, "events") + .QueryString("filter", "critical:false") + .QueryString("mode", "stack_frequent") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(results); + Assert.Single(results); + } + [Fact] public async Task CanGetNewStackMode() { diff --git a/tests/Exceptionless.Tests/Api/Endpoints/StackEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/StackEndpointTests.cs index dbc2977a51..440e74bed8 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/StackEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/StackEndpointTests.cs @@ -66,6 +66,21 @@ public async Task CanSearchByNonPremiumFields() Assert.Single(result); } + [Fact] + public async Task Handle_GetStacksByProjectWithPremiumFilterOnFreeOrganization_ReturnsUpgradeRequired() + { + // Arrange + await CreateDataAsync(d => d.Event().FreeProject().Message("Premium restricted stack")); + + // Act & Assert + await SendRequestAsync(r => r + .AsFreeOrganizationUser() + .AppendPaths("projects", SampleDataService.FREE_PROJECT_ID, "stacks") + .QueryString("filter", "title:\"Premium restricted stack\"") + .StatusCodeShouldBeUpgradeRequired() + ); + } + [Theory] [InlineData(null)] [InlineData("1.0.0")] diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 9561adc053..948509df19 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -187,6 +187,15 @@ public async Task GetOpenApiJson_MigratedContracts_PreserveControllerMetadata() String.Equals(parameter.GetProperty("name").GetString(), "expected_stack_id", StringComparison.Ordinal)); AssertResponseCodes(eventById, "200", "400", "404", "426"); + foreach (string path in new[] { "/api/v2/events/count", "/api/v2/organizations/{organizationId}/events/count", "/api/v2/projects/{projectId}/events/count" }) + AssertPathResponseCodes(paths, path, "get", "200", "400", "426"); + + foreach (string path in new[] { "/api/v2/events", "/api/v2/organizations/{organizationId}/events", "/api/v2/projects/{projectId}/events", "/api/v2/stacks/{stackId}/events" }) + AssertPathResponseCodes(paths, path, "get", "200", "400", "426"); + + foreach (string path in new[] { "/api/v2/stacks", "/api/v2/organizations/{organizationId}/stacks", "/api/v2/projects/{projectId}/stacks" }) + AssertPathResponseCodes(paths, path, "get", "200", "400", "426"); + foreach (string path in new[] { "/api/v1/events", "/api/v1/projects/{projectId}/events", "/api/v2/events", "/api/v2/projects/{projectId}/events" }) { var eventPost = paths.GetProperty(path).GetProperty("post"); @@ -219,6 +228,15 @@ private static void AssertResponseCodes(JsonElement operation, params string[] e Assert.True(responses.TryGetProperty(statusCode, out _), $"Expected response status code '{statusCode}'."); } + private static void AssertPathResponseCodes(JsonElement paths, string path, string method, params string[] expectedStatusCodes) + { + var operation = paths.GetProperty(path).GetProperty(method); + var responses = operation.GetProperty("responses"); + string actualStatusCodes = String.Join(", ", responses.EnumerateObject().Select(response => response.Name)); + foreach (string statusCode in expectedStatusCodes) + Assert.True(responses.TryGetProperty(statusCode, out _), $"Expected response status code '{statusCode}' for {method.ToUpperInvariant()} {path}. Actual: {actualStatusCodes}."); + } + private static void AssertArrayResponseSchema(JsonElement paths, string path, string expectedItemSchema) { var schema = paths.GetProperty(path) diff --git a/tests/http/events.http b/tests/http/events.http index 271fdcaaa6..6719b17f61 100644 --- a/tests/http/events.http +++ b/tests/http/events.http @@ -49,10 +49,18 @@ Authorization: Bearer {{token}} GET {{apiUrl}}/projects/{{projectId}}/events Authorization: Bearer {{token}} +### By Project With Premium Filter (returns 426 for a free organization) +GET {{apiUrl}}/projects/{{projectId}}/events?filter=tags:important +Authorization: Bearer {{token}} + ### Count GET {{apiUrl}}/events/count?aggregations=date:(date~month+cardinality:stack+sum:count~1)+cardinality:stack+terms:(first+@include:true)+sum:count~1&filter=(status:open+OR+status:regressed) Authorization: Bearer {{token}} +### Count By Project With Premium Filter (returns 426 for a free organization) +GET {{apiUrl}}/projects/{{projectId}}/events/count?filter=tags:important +Authorization: Bearer {{token}} + ### Simple Strings POST {{apiUrl}}/events?access_token={{clientToken}} Content-Type: application/json diff --git a/tests/http/stacks.http b/tests/http/stacks.http index db3f18b9cf..d17ddbd62f 100644 --- a/tests/http/stacks.http +++ b/tests/http/stacks.http @@ -39,6 +39,10 @@ Authorization: Bearer {{token}} GET {{apiUrl}}/stacks?organization={{organizationId}}&filter=tag:production Authorization: Bearer {{token}} +### Get By Project With Premium Filter (returns 426 for a free organization) +GET {{apiUrl}}/projects/{{projectId}}/stacks?filter=title:timeout +Authorization: Bearer {{token}} + ### Get By Organization Id with Filter (e.g., stacks with type and status) GET {{apiUrl}}/stacks?organization={{organizationId}}&filter=type:error%20status:open Authorization: Bearer {{token}}