Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions BotSharp.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,26 @@ public async Task ScheduledTimeArrived(CrontabItem item)
return;
}

await HookEmitter.Emit<ICrontabHook>(_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<ICrontabHook>(_services, hook =>
{
if (hook.Triggers == null || hook.Triggers.Contains(item.Title))
{
hook.OnAuthenticate(item);
}
}, item.AgentId);

// Phase 2 - Execute.
await HookEmitter.Emit<ICrontabHook>(_services, async hook =>
{
if (hook.Triggers == null || hook.Triggers.Contains(item.Title))
{
Comment on lines +136 to +148

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Case-sensitive triggers.contains match 📘 Rule violation ≡ Correctness

hook.Triggers.Contains(item.Title) uses default (case-sensitive/culture-sensitive) string
equality, which can break identifier-like trigger matching when casing differs. This violates the
requirement to use ordinal, case-insensitive comparisons/collections for identifier lookups.
Agent Prompt
## Issue description
`ScheduledTimeArrived` matches triggers using `hook.Triggers.Contains(item.Title)` which is case-sensitive by default. The compliance requirement is to use ordinal, case-insensitive comparisons for identifier-like strings.

## Issue Context
This PR introduced/rewrote the trigger-filtering logic in `ScheduledTimeArrived` (now executed in two phases), and the trigger match is performed in both phases.

## Fix Focus Areas
- src/Infrastructure/BotSharp.Core.Crontab/Services/CrontabService.cs[136-148]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

await hook.OnTaskExecuting(item);
await hook.OnCronTriggered(item);
await hook.OnTaskExecuted(item);
Comment on lines +136 to 151

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Hook instances may differ 🐞 Bug ≡ Correctness

ScheduledTimeArrived now resolves ICrontabHook implementations twice (once for Phase 1 auth, again
for Phase 2 execution); if any hook is registered as transient, OnAuthenticate can run on a
different instance than OnCronTriggered, breaking per-hook state that was initialized during
authentication.
Agent Prompt
## Issue description
`CrontabService.ScheduledTimeArrived` performs two separate `HookEmitter.Emit` calls, which re-resolve hooks from DI each time. This can produce different hook instances for transient lifetimes, causing `OnAuthenticate` state to be lost before `OnTaskExecuting/OnCronTriggered/OnTaskExecuted`.

## Issue Context
- Today, the known `ICrontabHook` registrations in-repo are `AddScoped`, so this may not reproduce right now, but the contract doesn’t enforce that and plugins can introduce transient hooks.

## Fix Focus Areas
- Resolve the hook list once and reuse it for both phases (or add an overload that accepts an already-resolved hook list).
- file: src/Infrastructure/BotSharp.Core.Crontab/Services/CrontabService.cs[129-153]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\src\Infrastructure\BotSharp.Core\BotSharp.Core.csproj" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Infrastructure\BotSharp.Core\BotSharp.Core.csproj" />
</ItemGroup>

</Project>
237 changes: 237 additions & 0 deletions tests/BotSharp.Core.UnitTests/Crontab/CrontabIdentityFlowTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
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;

/// <summary>
/// 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.
/// </summary>
public class CrontabIdentityFlowTests
{
private const string OperatorUid = "2";
private const string Trigger = "storm-priority-label";

/// <summary>Faithful mirror of UserProfileService.SetUserIdentity, including the reset branch.</summary>
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;

/// <summary>Mirror of OneOperatorUserIdentityCrontabHook: Triggers==null (runs for every item),
/// sets the default identity in OnAuthenticate via the real SetUserIdentity logic.</summary>
private sealed class IdentityHook(IHttpContextAccessor accessor, bool reset) : ICrontabHook
{
public string[]? Triggers => null;
public void OnAuthenticate(CrontabItem item) => SetUserIdentity(accessor, OperatorUid, reset);
}

/// <summary>Mirror of a worker hook (WoResidentInHotelLabelCrontabHook): does its work in
/// OnCronTriggered; the "outbound API" reads the ambient identity via the accessor.</summary>
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<IHttpContextAccessor>(accessor);
sc.AddSingleton<ICrontabHook>(new IdentityHook(accessor, identityReset)); // global identity hook, registered first
sc.AddSingleton<ICrontabHook>(worker);

return (sc.BuildServiceProvider(), accessor, worker);
}

private static CrontabItem NewItem() => new() { Title = Trigger, AgentId = string.Empty };

// ---------------------------------------------------------------------------------------------
// 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 PerHookLambda_structure_identity_does_NOT_reach_worker()
{
// OneOperatorUserIdentityCrontabHook historically used SetUserIdentity(..., []) => reset:false
var (services, _, worker) = Build(identityReset: false);
var item = NewItem();

// 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<ICrontabHook>(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)]
[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();

// 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<ICrontabHook>(services, hook =>
{
if (hook.Triggers == null || hook.Triggers.Contains(item.Title))
hook.OnAuthenticate(item);
}, item.AgentId);

// Phase 2: the async Emit overload runs the work.
await HookEmitter.Emit<ICrontabHook>(services, async hook =>
{
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);
}

// ---------------------------------------------------------------------------------------------
// 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 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 };

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++)
{
// 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);
}
}

await Task.WhenAll(HttpRequest(), CronJob());
}
}
Loading