diff --git a/README.md b/README.md index e53da947ee..35485632f9 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ Notes: 1. Running `Exceptionless.AppHost` starts the app and required infrastructure together. 2. Backend tests bootstrap required infrastructure automatically. -3. Use Aspire CLI 13.6.0 or newer (`aspire update --self` to update). The AppHost uses the CLI bundle for the dashboard and resource orchestrator; when no CLI is installed, the SDK downloads the matching version through DNX. +3. Use Aspire CLI 13.6.5 or newer (`aspire update --self` to update). The AppHost uses the CLI bundle for the dashboard and resource orchestrator; when no CLI is installed, the SDK downloads the matching version through DNX. +4. Try `dotnet build -mt` for multithreaded builds. Performance depends on the workload and machine. ## UI Development diff --git a/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs b/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs index 98aa5b4f6b..3d57154a20 100644 --- a/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs +++ b/src/Exceptionless.Core/Extensions/PersistentEventExtensions.cs @@ -156,7 +156,8 @@ public static bool UpdateSessionStart(this PersistentEvent ev, DateTime lastActi if (duration < 0) duration = 0; - decimal newDuration = (decimal)(lastActivityUtc - ev.Date.UtcDateTime).TotalSeconds; + // Avoid losing tick precision through TotalSeconds' double representation. + decimal newDuration = (lastActivityUtc - ev.Date.UtcDateTime).Ticks / (decimal)TimeSpan.TicksPerSecond; if (duration >= newDuration) lastActivityUtc = ev.Date.UtcDateTime.AddSeconds((double)duration); else diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 4010d85b3e..1664c77105 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -755,6 +755,12 @@ export interface User { id: string; /** The organizations that the user has access to. */ organization_ids: string[]; + /** + * Session revocation generation, rotated when a password changes or resets, or an external login is removed or transferred away. + * Remains null until rotation; active users with a null generation accept only matching legacy null token generations. + * This value does not describe the password hashing format. + */ + authentication_version?: null | string; password?: null | string; salt?: null | string; password_reset_token?: null | string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index a0ad26433e..71367e1d2f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -889,6 +889,7 @@ export const UserSchema = object({ .length(24, "Id must be exactly 24 characters") .regex(/^[a-fA-F0-9]{24}$/, "Id has invalid format"), organization_ids: array(string()), + authentication_version: string().nullable().optional(), password: string().min(1, "Password is required").nullable().optional(), salt: string().min(1, "Salt is required").nullable().optional(), password_reset_token: string() diff --git a/src/Exceptionless.Web/Utility/OpenApi/ExceptionlessOpenApiServiceCollectionExtensions.cs b/src/Exceptionless.Web/Utility/OpenApi/ExceptionlessOpenApiServiceCollectionExtensions.cs index 667623ba32..829c3a2243 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/ExceptionlessOpenApiServiceCollectionExtensions.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/ExceptionlessOpenApiServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.DependencyInjection; -using Microsoft.OpenApi; namespace Exceptionless.Web.Utility.OpenApi; @@ -11,8 +10,6 @@ public static IServiceCollection AddExceptionlessOpenApi(this IServiceCollection { services.AddOpenApi(options => { - // ASP.NET Core 11 defaults to OpenAPI 3.2; keep publishing 3.1 for existing client generators. - options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; options.CreateSchemaReferenceId = SchemaReferenceIdHelper.CreateSchemaReferenceId; options.AddDocumentTransformer(); options.AddDocumentTransformer(); diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index dbca567ca0..f8abf4f1e5 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -1,5 +1,5 @@ { - "openapi": "3.1.2", + "openapi": "3.2.0", "info": { "title": "Exceptionless API", "termsOfService": "https://exceptionless.com/terms/", @@ -15224,4 +15224,4 @@ "name": "WebHook" } ] -} +} \ No newline at end of file diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index dbd4f791cb..7fcf38ad6d 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -1104,6 +1104,9 @@ public async Task AddUserAsync_ExistingUserWithoutMembership_AddsOrganizationMem var existingUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.FREE_USER_EMAIL); Assert.NotNull(existingUser); Assert.DoesNotContain(SampleDataService.TEST_ORG_ID, existingUser.OrganizationIds); + existingUser.AuthenticationVersion = "private-authentication-version"; + existingUser.PasswordResetToken = "private-password-reset-token"; + await _userRepository.SaveAsync(existingUser, o => o.ImmediateConsistency().Cache()); // Act var user = await SendRequestAsAsync(r => r @@ -1116,6 +1119,11 @@ public async Task AddUserAsync_ExistingUserWithoutMembership_AddsOrganizationMem // Assert Assert.NotNull(user); Assert.Equal(SampleDataService.FREE_USER_EMAIL, user.EmailAddress); + Assert.Null(user.AuthenticationVersion); + Assert.Null(user.Password); + Assert.Null(user.Salt); + Assert.Null(user.PasswordResetToken); + Assert.Null(user.VerifyEmailAddressToken); existingUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.FREE_USER_EMAIL); Assert.NotNull(existingUser); diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 06dd4f3b17..149624ce8d 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -47,6 +47,7 @@ public async Task GetOpenApiJson_Default_ContainsExpectedRoutesOperationsAndResp var paths = document.RootElement.GetProperty("paths"); // Assert + Assert.Equal("3.2.0", document.RootElement.GetProperty("openapi").GetString()); 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 _)); diff --git a/tests/Exceptionless.Tests/Assistant/AssistantServiceTests.cs b/tests/Exceptionless.Tests/Assistant/AssistantServiceTests.cs index d2d307d96f..ac221f6e07 100644 --- a/tests/Exceptionless.Tests/Assistant/AssistantServiceTests.cs +++ b/tests/Exceptionless.Tests/Assistant/AssistantServiceTests.cs @@ -1687,6 +1687,7 @@ public async Task StreamAsync_ProviderThrows_RecordsFailureCategory(string failu [InlineData("{\"usage\":{\"prompt_tokens\":\"private invalid token count\"}}")] public async Task StreamAsync_InvalidProviderShape_RecordsProviderAndTurnFailure(string payload) { + // Arrange var options = AppOptions.ReadFromConfiguration(new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["BaseURL"] = "https://localhost", ["Assistant:ApiKey"] = "test-key" }) .Build()); @@ -1701,17 +1702,23 @@ public async Task StreamAsync_InvalidProviderShape_RecordsProviderAndTurnFailure using var response = new MemoryStream(); context.Response.Body = response; + // Act await AssistantEndpoints.WriteResponseAsync(context, service.StreamAsync(new AssistantChatRequest([new AssistantChatMessage("user", "private question")]), "user-id", CreatePlanOptions(), diagnostics, TestContext.Current.CancellationToken), usageService, "organization-id", diagnostics, TestContext.Current.CancellationToken); + // Assert var providerEntry = Assert.Single(logger.Entries, entry => entry.Properties.ContainsKey("ProviderOutcome")); var turnEntry = Assert.Single(logger.Entries, entry => entry.Properties.ContainsKey("Outcome")); Assert.Equal("invalid_provider_response", providerEntry.Properties["ProviderOutcome"]); Assert.Equal("invalid_provider_response", turnEntry.Properties["FailureReason"]); Assert.Equal("failed", turnEntry.Properties["Outcome"]); - Assert.All(logger.Entries, entry => Assert.DoesNotContain("private", entry.Message)); + Assert.All(logger.Entries, entry => + { + Assert.DoesNotContain("private question", entry.Message); + Assert.DoesNotContain("private invalid token count", entry.Message); + }); } [Theory] diff --git a/tests/Exceptionless.Tests/Extensions/PersistentEventExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/PersistentEventExtensionsTests.cs new file mode 100644 index 0000000000..bce52641f9 --- /dev/null +++ b/tests/Exceptionless.Tests/Extensions/PersistentEventExtensionsTests.cs @@ -0,0 +1,25 @@ +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Foundatio.Xunit; +using Xunit; + +namespace Exceptionless.Tests.Extensions; + +public sealed class PersistentEventExtensionsTests(ITestOutputHelper output) : TestWithLoggingBase(output) +{ + [Fact] + public void UpdateSessionStart_FractionalSeconds_PreservesTickPrecision() + { + // Arrange + var start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + var session = new PersistentEvent { Type = Event.KnownTypes.Session, Date = start }; + var lastActivity = start.UtcDateTime.AddTicks(1_234_567_891); + + // Act + bool updated = session.UpdateSessionStart(lastActivity); + + // Assert + Assert.True(updated); + Assert.Equal(123.4567891m, session.Value); + } +}