From 0f0ac7505eb700615a572b75550f053a30e0d284 Mon Sep 17 00:00:00 2001 From: "nick.yi" Date: Tue, 14 Jul 2026 14:22:47 +0800 Subject: [PATCH 1/3] add CrontabIdentityFlowTests --- .../BotSharp.Core.UnitTests.csproj | 4 + .../Crontab/CrontabIdentityFlowTests.cs | 202 ++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 BotSharp.Core.UnitTests/Crontab/CrontabIdentityFlowTests.cs diff --git a/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj b/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj index b050e4daa..d17ce7215 100644 --- a/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj +++ b/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj @@ -21,6 +21,10 @@ + + + + diff --git a/BotSharp.Core.UnitTests/Crontab/CrontabIdentityFlowTests.cs b/BotSharp.Core.UnitTests/Crontab/CrontabIdentityFlowTests.cs new file mode 100644 index 000000000..ec0554851 --- /dev/null +++ b/BotSharp.Core.UnitTests/Crontab/CrontabIdentityFlowTests.cs @@ -0,0 +1,202 @@ +using System.Security.Claims; +using BotSharp.Abstraction.Crontab; +using BotSharp.Abstraction.Crontab.Models; +using BotSharp.Core.Infrastructures; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace BotSharp.Core.UnitTests.Crontab; + +/// +/// Reproduces the crontab identity-propagation bug and validates the two-phase fix, and separately +/// pins down the effect of the isResetHttpContext flag on UserProfileService.SetUserIdentity. +/// +/// Root cause of the bug: identity is written to the process-wide IHttpContextAccessor / +/// Thread.CurrentPrincipal (both backed by a static AsyncLocal). In HookEmitter.Emit each hook runs +/// inside its OWN awaited async lambda (`await action(hook)`); the async state machine restores the +/// ExecutionContext when that lambda completes, so an AsyncLocal write made in hook A's OnAuthenticate +/// is discarded before hook B's OnCronTriggered runs. +/// +public class CrontabIdentityFlowTests +{ + private const string OperatorUid = "2"; + private const string Trigger = "storm-priority-label"; + + /// Faithful mirror of UserProfileService.SetUserIdentity, including the reset branch. + private static void SetUserIdentity(IHttpContextAccessor accessor, string uid, bool isResetHttpContext) + { + if (isResetHttpContext || accessor.HttpContext == null) + accessor.HttpContext = new DefaultHttpContext(); + + accessor.HttpContext!.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("uid", uid) })); + Thread.CurrentPrincipal = accessor.HttpContext.User; + } + + // == CurrentUserIdentity reading accessor.HttpContext.User.Claims for the outbound call + private static string? ReadUid(IHttpContextAccessor accessor) + => accessor.HttpContext?.User?.FindFirst("uid")?.Value; + + /// Mirror of OneOperatorUserIdentityCrontabHook: Triggers==null (runs for every item), + /// sets the default identity in OnAuthenticate via the real SetUserIdentity logic. + private sealed class IdentityHook(IHttpContextAccessor accessor, bool reset) : ICrontabHook + { + public string[]? Triggers => null; + public void OnAuthenticate(CrontabItem item) => SetUserIdentity(accessor, OperatorUid, reset); + } + + /// Mirror of a worker hook (WoResidentInHotelLabelCrontabHook): does its work in + /// OnCronTriggered; the "outbound API" reads the ambient identity via the accessor. + private sealed class WorkerHook(IHttpContextAccessor accessor) : ICrontabHook + { + public string? ObservedUid { get; private set; } + public string[]? Triggers => new[] { Trigger }; + public Task OnCronTriggered(CrontabItem item) + { + ObservedUid = ReadUid(accessor); + return Task.CompletedTask; + } + } + + private static (IServiceProvider services, IHttpContextAccessor accessor, WorkerHook worker) Build(bool identityReset) + { + var accessor = new HttpContextAccessor { HttpContext = null }; + var worker = new WorkerHook(accessor); + + var sc = new ServiceCollection(); + sc.AddLogging(); + sc.AddSingleton(accessor); + sc.AddSingleton(new IdentityHook(accessor, identityReset)); // global identity hook, registered first + sc.AddSingleton(worker); + + return (sc.BuildServiceProvider(), accessor, worker); + } + + private static CrontabItem NewItem() => new() { Title = Trigger, AgentId = string.Empty }; + + // --------------------------------------------------------------------------------------------- + // Identity flow: separate identity hook -> worker hook + // --------------------------------------------------------------------------------------------- + + [Fact] + public async Task Current_structure_identity_does_NOT_reach_worker() + { + // production today: OneOperatorUserIdentityCrontabHook uses SetUserIdentity(..., []) => reset:false + var (services, _, worker) = Build(identityReset: false); + var item = NewItem(); + + // EXACT structure of CrontabService.ScheduledTimeArrived today, via the real HookEmitter.Emit: + // OnAuthenticate + OnCronTriggered inside the same per-hook awaited lambda, one lambda per hook. + await HookEmitter.Emit(services, async hook => + { + if (hook.Triggers == null || hook.Triggers.Contains(item.Title)) + { + hook.OnAuthenticate(item); + await hook.OnTaskExecuting(item); + await hook.OnCronTriggered(item); + await hook.OnTaskExecuted(item); + } + }, item.AgentId); + + // BUG: the identity hook's OnAuthenticate write was reverted when its lambda completed. + Assert.NotEqual(OperatorUid, worker.ObservedUid); + Assert.Null(worker.ObservedUid); + } + + [Theory] + [InlineData(true)] // fixed hook: reset:true + [InlineData(false)] // even reset:false works here, because two-phase is what actually fixes the flow + public async Task TwoPhase_structure_identity_reaches_worker(bool identityReset) + { + var (services, _, worker) = Build(identityReset); + var item = NewItem(); + + var hooks = services.GetServices() + .Where(h => h.Triggers == null || h.Triggers.Contains(item.Title)) + .ToList(); + + // THE FIX: Phase 1 runs every OnAuthenticate synchronously in the method body (NOT wrapped in + // an awaited per-hook lambda), so the identity written to AsyncLocal flows DOWN into Phase 2. + foreach (var hook in hooks) + hook.OnAuthenticate(item); + + foreach (var hook in hooks) + { + await hook.OnTaskExecuting(item); + await hook.OnCronTriggered(item); + await hook.OnTaskExecuted(item); + } + + Assert.Equal(OperatorUid, worker.ObservedUid); + } + + // --------------------------------------------------------------------------------------------- + // Effect of isResetHttpContext + // --------------------------------------------------------------------------------------------- + + // Simulates a DelegatingHandler (MeshAuthHeaderHandler) awaited from within a live HTTP request. + private static async Task InRequestHandler(Action setIdentity) + { + setIdentity(); + await Task.Delay(1); + } + + [Fact] + public async Task Reset_false_in_a_live_request_only_overwrites_User_and_keeps_the_context() + { + var accessor = new HttpContextAccessor { HttpContext = null }; + // auth middleware established the request context + real user 555 + accessor.HttpContext = new DefaultHttpContext(); + accessor.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("uid", "555") })); + + await InRequestHandler(() => SetUserIdentity(accessor, OperatorUid, isResetHttpContext: false)); + + // rest of the request pipeline still has a context; only the user was overwritten (the intended + // fallback behaviour in MeshAuthHeaderHandler when no user is resolved). + Assert.NotNull(accessor.HttpContext); + Assert.Equal(OperatorUid, ReadUid(accessor)); + } + + [Fact] + public async Task Reset_true_in_a_live_request_DESTROYS_the_request_context() + { + var accessor = new HttpContextAccessor { HttpContext = null }; + accessor.HttpContext = new DefaultHttpContext(); + accessor.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("uid", "555") })); + + await InRequestHandler(() => SetUserIdentity(accessor, OperatorUid, isResetHttpContext: true)); + + // the accessor setter did `holder.Context = null` on the request's shared holder; after the + // awaited handler unwinds, the request pipeline reads a NULL context -> lost user / NRE. + // This is why the global default must NOT be flipped to true. + Assert.Null(accessor.HttpContext); + } + + [Fact] + public async Task Reset_true_cron_job_does_NOT_pollute_a_concurrent_http_request() + { + var accessor = new HttpContextAccessor { HttpContext = null }; + + async Task HttpRequest() + { + SetUserIdentity(accessor, "777", isResetHttpContext: true); // request's real user + for (var i = 0; i < 20; i++) + { + await Task.Delay(1); + Assert.Equal("777", ReadUid(accessor)); // must never see the cron identity + } + } + + async Task CronJob() + { + for (var i = 0; i < 20; i++) + { + SetUserIdentity(accessor, OperatorUid, isResetHttpContext: true); // background default + await Task.Delay(1); + } + } + + await Task.WhenAll(HttpRequest(), CronJob()); + } +} From 2a237fba7fcb440d790c74ae2fa78fc2e0d5c291 Mon Sep 17 00:00:00 2001 From: "nick.yi" Date: Tue, 14 Jul 2026 14:47:32 +0800 Subject: [PATCH 2/3] optimze crontab identity --- BotSharp.sln | 30 +++---- .../Services/CrontabService.cs | 17 +++- .../BotSharp.Core.UnitTests.csproj | 0 .../Crontab/CrontabIdentityFlowTests.cs | 79 +++++++++++++------ .../Infrastructures/SettingServiceTests.cs | 0 .../Routing/RoutingRuleTests.cs | 0 6 files changed, 88 insertions(+), 38 deletions(-) rename {BotSharp.Core.UnitTests => tests/BotSharp.Core.UnitTests}/BotSharp.Core.UnitTests.csproj (100%) rename {BotSharp.Core.UnitTests => tests/BotSharp.Core.UnitTests}/Crontab/CrontabIdentityFlowTests.cs (70%) rename {BotSharp.Core.UnitTests => tests/BotSharp.Core.UnitTests}/Infrastructures/SettingServiceTests.cs (100%) rename {BotSharp.Core.UnitTests => tests/BotSharp.Core.UnitTests}/Routing/RoutingRuleTests.cs (100%) diff --git a/BotSharp.sln b/BotSharp.sln index 31ce5133e..fc6036727 100644 --- a/BotSharp.sln +++ b/BotSharp.sln @@ -155,14 +155,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Core.A2A", "src\In EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.MultiTenancy", "src\Plugins\BotSharp.Plugin.MultiTenancy\BotSharp.Plugin.MultiTenancy.csproj", "{562DD0C6-DAC8-02CC-C1DD-D43DF186CE76}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Core.UnitTests", "BotSharp.Core.UnitTests\BotSharp.Core.UnitTests.csproj", "{53E53E98-3C14-4AED-AC40-BD88170C5FE5}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", "{58D3A2C3-F96F-5E57-2C6B-ECE59D6A18FC}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.MicrosoftTeams", "src\Plugins\BotSharp.Plugin.MicrosoftTeams\BotSharp.Plugin.MicrosoftTeams.csproj", "{19FA8311-69D1-4729-AE9B-D10979C099EC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Core.UnitTests", "tests\BotSharp.Core.UnitTests\BotSharp.Core.UnitTests.csproj", "{3585AE68-43CE-B724-72C1-579D9FFE1D6E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -917,18 +917,6 @@ Global {562DD0C6-DAC8-02CC-C1DD-D43DF186CE76}.Release|x64.Build.0 = Release|Any CPU {562DD0C6-DAC8-02CC-C1DD-D43DF186CE76}.Release|x86.ActiveCfg = Release|Any CPU {562DD0C6-DAC8-02CC-C1DD-D43DF186CE76}.Release|x86.Build.0 = Release|Any CPU - {53E53E98-3C14-4AED-AC40-BD88170C5FE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {53E53E98-3C14-4AED-AC40-BD88170C5FE5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {53E53E98-3C14-4AED-AC40-BD88170C5FE5}.Debug|x64.ActiveCfg = Debug|Any CPU - {53E53E98-3C14-4AED-AC40-BD88170C5FE5}.Debug|x64.Build.0 = Debug|Any CPU - {53E53E98-3C14-4AED-AC40-BD88170C5FE5}.Debug|x86.ActiveCfg = Debug|Any CPU - {53E53E98-3C14-4AED-AC40-BD88170C5FE5}.Debug|x86.Build.0 = Debug|Any CPU - {53E53E98-3C14-4AED-AC40-BD88170C5FE5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {53E53E98-3C14-4AED-AC40-BD88170C5FE5}.Release|Any CPU.Build.0 = Release|Any CPU - {53E53E98-3C14-4AED-AC40-BD88170C5FE5}.Release|x64.ActiveCfg = Release|Any CPU - {53E53E98-3C14-4AED-AC40-BD88170C5FE5}.Release|x64.Build.0 = Release|Any CPU - {53E53E98-3C14-4AED-AC40-BD88170C5FE5}.Release|x86.ActiveCfg = Release|Any CPU - {53E53E98-3C14-4AED-AC40-BD88170C5FE5}.Release|x86.Build.0 = Release|Any CPU {19FA8311-69D1-4729-AE9B-D10979C099EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {19FA8311-69D1-4729-AE9B-D10979C099EC}.Debug|Any CPU.Build.0 = Debug|Any CPU {19FA8311-69D1-4729-AE9B-D10979C099EC}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -941,6 +929,18 @@ Global {19FA8311-69D1-4729-AE9B-D10979C099EC}.Release|x64.Build.0 = Release|Any CPU {19FA8311-69D1-4729-AE9B-D10979C099EC}.Release|x86.ActiveCfg = Release|Any CPU {19FA8311-69D1-4729-AE9B-D10979C099EC}.Release|x86.Build.0 = Release|Any CPU + {3585AE68-43CE-B724-72C1-579D9FFE1D6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3585AE68-43CE-B724-72C1-579D9FFE1D6E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3585AE68-43CE-B724-72C1-579D9FFE1D6E}.Debug|x64.ActiveCfg = Debug|Any CPU + {3585AE68-43CE-B724-72C1-579D9FFE1D6E}.Debug|x64.Build.0 = Debug|Any CPU + {3585AE68-43CE-B724-72C1-579D9FFE1D6E}.Debug|x86.ActiveCfg = Debug|Any CPU + {3585AE68-43CE-B724-72C1-579D9FFE1D6E}.Debug|x86.Build.0 = Debug|Any CPU + {3585AE68-43CE-B724-72C1-579D9FFE1D6E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3585AE68-43CE-B724-72C1-579D9FFE1D6E}.Release|Any CPU.Build.0 = Release|Any CPU + {3585AE68-43CE-B724-72C1-579D9FFE1D6E}.Release|x64.ActiveCfg = Release|Any CPU + {3585AE68-43CE-B724-72C1-579D9FFE1D6E}.Release|x64.Build.0 = Release|Any CPU + {3585AE68-43CE-B724-72C1-579D9FFE1D6E}.Release|x86.ActiveCfg = Release|Any CPU + {3585AE68-43CE-B724-72C1-579D9FFE1D6E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1016,9 +1016,9 @@ Global {13223C71-9EAC-9835-28ED-5A4833E6F915} = {53E7CD86-0D19-40D9-A0FA-AB4613837E89} {E8D01281-D52A-BFF4-33DB-E35D91754272} = {E29DC6C4-5E57-48C5-BCB0-6B8F84782749} {562DD0C6-DAC8-02CC-C1DD-D43DF186CE76} = {51AFE054-AE99-497D-A593-69BAEFB5106F} - {53E53E98-3C14-4AED-AC40-BD88170C5FE5} = {32FAFFFE-A4CB-4FEE-BF7C-84518BBC6DCC} {58D3A2C3-F96F-5E57-2C6B-ECE59D6A18FC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {19FA8311-69D1-4729-AE9B-D10979C099EC} = {58D3A2C3-F96F-5E57-2C6B-ECE59D6A18FC} + {3585AE68-43CE-B724-72C1-579D9FFE1D6E} = {32FAFFFE-A4CB-4FEE-BF7C-84518BBC6DCC} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A9969D89-C98B-40A5-A12B-FC87E55B3A19} diff --git a/src/Infrastructure/BotSharp.Core.Crontab/Services/CrontabService.cs b/src/Infrastructure/BotSharp.Core.Crontab/Services/CrontabService.cs index 22d5f9c9d..16fdf8491 100644 --- a/src/Infrastructure/BotSharp.Core.Crontab/Services/CrontabService.cs +++ b/src/Infrastructure/BotSharp.Core.Crontab/Services/CrontabService.cs @@ -126,11 +126,26 @@ public async Task ScheduledTimeArrived(CrontabItem item) return; } - await HookEmitter.Emit(_services, async hook => + // Phase 1 - Authenticate. Use the SYNCHRONOUS Emit overload: OnAuthenticate runs as a plain + // synchronous delegate call (no async state machine, so no ExecutionContext capture/restore). + // The identity a hook writes to the ambient AsyncLocal (IHttpContextAccessor / + // Thread.CurrentPrincipal) therefore persists in this method's context and flows DOWN into the + // Phase 2 OnCronTriggered awaits below. If authentication ran inside the awaited (async) Emit + // overload, the async state machine would restore the ExecutionContext on each hook's + // completion and discard the identity before the work runs. + HookEmitter.Emit(_services, hook => { if (hook.Triggers == null || hook.Triggers.Contains(item.Title)) { hook.OnAuthenticate(item); + } + }, item.AgentId); + + // Phase 2 - Execute. + await HookEmitter.Emit(_services, async hook => + { + if (hook.Triggers == null || hook.Triggers.Contains(item.Title)) + { await hook.OnTaskExecuting(item); await hook.OnCronTriggered(item); await hook.OnTaskExecuted(item); diff --git a/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj b/tests/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj similarity index 100% rename from BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj rename to tests/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj diff --git a/BotSharp.Core.UnitTests/Crontab/CrontabIdentityFlowTests.cs b/tests/BotSharp.Core.UnitTests/Crontab/CrontabIdentityFlowTests.cs similarity index 70% rename from BotSharp.Core.UnitTests/Crontab/CrontabIdentityFlowTests.cs rename to tests/BotSharp.Core.UnitTests/Crontab/CrontabIdentityFlowTests.cs index ec0554851..fbfb69c4c 100644 --- a/BotSharp.Core.UnitTests/Crontab/CrontabIdentityFlowTests.cs +++ b/tests/BotSharp.Core.UnitTests/Crontab/CrontabIdentityFlowTests.cs @@ -79,15 +79,18 @@ private static (IServiceProvider services, IHttpContextAccessor accessor, Worker // Identity flow: separate identity hook -> worker hook // --------------------------------------------------------------------------------------------- + // Regression guard: this is the PRE-FIX structure CrontabService.ScheduledTimeArrived used + // (OnAuthenticate wrapped in the per-hook awaited HookEmitter.Emit lambda). It is kept to + // document why that structure is broken and to fail if anyone reintroduces it. [Fact] - public async Task Current_structure_identity_does_NOT_reach_worker() + public async Task PerHookLambda_structure_identity_does_NOT_reach_worker() { - // production today: OneOperatorUserIdentityCrontabHook uses SetUserIdentity(..., []) => reset:false + // OneOperatorUserIdentityCrontabHook historically used SetUserIdentity(..., []) => reset:false var (services, _, worker) = Build(identityReset: false); var item = NewItem(); - // EXACT structure of CrontabService.ScheduledTimeArrived today, via the real HookEmitter.Emit: - // OnAuthenticate + OnCronTriggered inside the same per-hook awaited lambda, one lambda per hook. + // The pre-fix structure, via the real HookEmitter.Emit: OnAuthenticate + OnCronTriggered + // inside the same per-hook awaited lambda, one lambda per hook. await HookEmitter.Emit(services, async hook => { if (hook.Triggers == null || hook.Triggers.Contains(item.Title)) @@ -105,28 +108,32 @@ await HookEmitter.Emit(services, async hook => } [Theory] - [InlineData(true)] // fixed hook: reset:true - [InlineData(false)] // even reset:false works here, because two-phase is what actually fixes the flow - public async Task TwoPhase_structure_identity_reaches_worker(bool identityReset) + [InlineData(true)] + [InlineData(false)] // reset flag is irrelevant here (no HttpContext); two-phase is what fixes the flow + public async Task TwoPhase_via_Emit_overloads_reaches_worker(bool identityReset) { var (services, _, worker) = Build(identityReset); var item = NewItem(); - var hooks = services.GetServices() - .Where(h => h.Triggers == null || h.Triggers.Contains(item.Title)) - .ToList(); - - // THE FIX: Phase 1 runs every OnAuthenticate synchronously in the method body (NOT wrapped in - // an awaited per-hook lambda), so the identity written to AsyncLocal flows DOWN into Phase 2. - foreach (var hook in hooks) - hook.OnAuthenticate(item); + // Mirrors the shipped CrontabService.ScheduledTimeArrived exactly. + // Phase 1: the SYNCHRONOUS Emit overload runs OnAuthenticate as a plain delegate call (no + // async state machine), so the identity persists and flows DOWN into Phase 2. + HookEmitter.Emit(services, hook => + { + if (hook.Triggers == null || hook.Triggers.Contains(item.Title)) + hook.OnAuthenticate(item); + }, item.AgentId); - foreach (var hook in hooks) + // Phase 2: the async Emit overload runs the work. + await HookEmitter.Emit(services, async hook => { - await hook.OnTaskExecuting(item); - await hook.OnCronTriggered(item); - await hook.OnTaskExecuted(item); - } + if (hook.Triggers == null || hook.Triggers.Contains(item.Title)) + { + await hook.OnTaskExecuting(item); + await hook.OnCronTriggered(item); + await hook.OnTaskExecuted(item); + } + }, item.AgentId); Assert.Equal(OperatorUid, worker.ObservedUid); } @@ -174,7 +181,33 @@ public async Task Reset_true_in_a_live_request_DESTROYS_the_request_context() } [Fact] - public async Task Reset_true_cron_job_does_NOT_pollute_a_concurrent_http_request() + public async Task When_no_http_context_the_reset_flag_does_not_matter() + { + // Point 2: in the crontab background flow HttpContext is null when OnAuthenticate runs, so + // SetUserIdentity takes the `|| HttpContext == null` branch and creates a fresh context + // whether isResetHttpContext is true or false. Each case runs in its own ExecutionContext. + var withFalse = await Task.Run(() => + { + var accessor = new HttpContextAccessor { HttpContext = null }; + SetUserIdentity(accessor, OperatorUid, isResetHttpContext: false); + return (hasContext: accessor.HttpContext != null, uid: ReadUid(accessor)); + }); + + var withTrue = await Task.Run(() => + { + var accessor = new HttpContextAccessor { HttpContext = null }; + SetUserIdentity(accessor, OperatorUid, isResetHttpContext: true); + return (hasContext: accessor.HttpContext != null, uid: ReadUid(accessor)); + }); + + Assert.True(withFalse.hasContext); + Assert.True(withTrue.hasContext); + Assert.Equal(OperatorUid, withFalse.uid); + Assert.Equal(withTrue.uid, withFalse.uid); // identical outcome + } + + [Fact] + public async Task Cron_identity_does_NOT_pollute_a_concurrent_http_request() { var accessor = new HttpContextAccessor { HttpContext = null }; @@ -192,7 +225,9 @@ async Task CronJob() { for (var i = 0; i < 20; i++) { - SetUserIdentity(accessor, OperatorUid, isResetHttpContext: true); // background default + // shipped background behaviour: reset:false, but HttpContext is null in this flow so + // a fresh context is created and never touches the request's context. + SetUserIdentity(accessor, OperatorUid, isResetHttpContext: false); await Task.Delay(1); } } diff --git a/BotSharp.Core.UnitTests/Infrastructures/SettingServiceTests.cs b/tests/BotSharp.Core.UnitTests/Infrastructures/SettingServiceTests.cs similarity index 100% rename from BotSharp.Core.UnitTests/Infrastructures/SettingServiceTests.cs rename to tests/BotSharp.Core.UnitTests/Infrastructures/SettingServiceTests.cs diff --git a/BotSharp.Core.UnitTests/Routing/RoutingRuleTests.cs b/tests/BotSharp.Core.UnitTests/Routing/RoutingRuleTests.cs similarity index 100% rename from BotSharp.Core.UnitTests/Routing/RoutingRuleTests.cs rename to tests/BotSharp.Core.UnitTests/Routing/RoutingRuleTests.cs From 21db0be571a7f95be40a781f120e91fea1f45cd0 Mon Sep 17 00:00:00 2001 From: "nick.yi" Date: Tue, 14 Jul 2026 14:49:36 +0800 Subject: [PATCH 3/3] add CrontabIdentityFlowTests --- tests/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj b/tests/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj index d17ce7215..85eef91b0 100644 --- a/tests/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj +++ b/tests/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj @@ -26,7 +26,7 @@ - +