@@ -79,15 +79,18 @@ private static (IServiceProvider services, IHttpContextAccessor accessor, Worker
7979 // Identity flow: separate identity hook -> worker hook
8080 // ---------------------------------------------------------------------------------------------
8181
82+ // Regression guard: this is the PRE-FIX structure CrontabService.ScheduledTimeArrived used
83+ // (OnAuthenticate wrapped in the per-hook awaited HookEmitter.Emit lambda). It is kept to
84+ // document why that structure is broken and to fail if anyone reintroduces it.
8285 [ Fact ]
83- public async Task Current_structure_identity_does_NOT_reach_worker ( )
86+ public async Task PerHookLambda_structure_identity_does_NOT_reach_worker ( )
8487 {
85- // production today: OneOperatorUserIdentityCrontabHook uses SetUserIdentity(..., []) => reset:false
88+ // OneOperatorUserIdentityCrontabHook historically used SetUserIdentity(..., []) => reset:false
8689 var ( services , _, worker ) = Build ( identityReset : false ) ;
8790 var item = NewItem ( ) ;
8891
89- // EXACT structure of CrontabService.ScheduledTimeArrived today , via the real HookEmitter.Emit:
90- // OnAuthenticate + OnCronTriggered inside the same per-hook awaited lambda, one lambda per hook.
92+ // The pre-fix structure , via the real HookEmitter.Emit: OnAuthenticate + OnCronTriggered
93+ // inside the same per-hook awaited lambda, one lambda per hook.
9194 await HookEmitter . Emit < ICrontabHook > ( services , async hook =>
9295 {
9396 if ( hook . Triggers == null || hook . Triggers . Contains ( item . Title ) )
@@ -105,28 +108,32 @@ await HookEmitter.Emit<ICrontabHook>(services, async hook =>
105108 }
106109
107110 [ Theory ]
108- [ InlineData ( true ) ] // fixed hook: reset:true
109- [ InlineData ( false ) ] // even reset:false works here, because two-phase is what actually fixes the flow
110- public async Task TwoPhase_structure_identity_reaches_worker ( bool identityReset )
111+ [ InlineData ( true ) ]
112+ [ InlineData ( false ) ] // reset flag is irrelevant here (no HttpContext); two-phase is what fixes the flow
113+ public async Task TwoPhase_via_Emit_overloads_reaches_worker ( bool identityReset )
111114 {
112115 var ( services , _, worker ) = Build ( identityReset ) ;
113116 var item = NewItem ( ) ;
114117
115- var hooks = services . GetServices < ICrontabHook > ( )
116- . Where ( h => h . Triggers == null || h . Triggers . Contains ( item . Title ) )
117- . ToList ( ) ;
118-
119- // THE FIX: Phase 1 runs every OnAuthenticate synchronously in the method body (NOT wrapped in
120- // an awaited per-hook lambda), so the identity written to AsyncLocal flows DOWN into Phase 2.
121- foreach ( var hook in hooks )
122- hook . OnAuthenticate ( item ) ;
118+ // Mirrors the shipped CrontabService.ScheduledTimeArrived exactly.
119+ // Phase 1: the SYNCHRONOUS Emit overload runs OnAuthenticate as a plain delegate call (no
120+ // async state machine), so the identity persists and flows DOWN into Phase 2.
121+ HookEmitter . Emit < ICrontabHook > ( services , hook =>
122+ {
123+ if ( hook . Triggers == null || hook . Triggers . Contains ( item . Title ) )
124+ hook . OnAuthenticate ( item ) ;
125+ } , item . AgentId ) ;
123126
124- foreach ( var hook in hooks )
127+ // Phase 2: the async Emit overload runs the work.
128+ await HookEmitter . Emit < ICrontabHook > ( services , async hook =>
125129 {
126- await hook . OnTaskExecuting ( item ) ;
127- await hook . OnCronTriggered ( item ) ;
128- await hook . OnTaskExecuted ( item ) ;
129- }
130+ if ( hook . Triggers == null || hook . Triggers . Contains ( item . Title ) )
131+ {
132+ await hook . OnTaskExecuting ( item ) ;
133+ await hook . OnCronTriggered ( item ) ;
134+ await hook . OnTaskExecuted ( item ) ;
135+ }
136+ } , item . AgentId ) ;
130137
131138 Assert . Equal ( OperatorUid , worker . ObservedUid ) ;
132139 }
@@ -174,7 +181,33 @@ public async Task Reset_true_in_a_live_request_DESTROYS_the_request_context()
174181 }
175182
176183 [ Fact ]
177- public async Task Reset_true_cron_job_does_NOT_pollute_a_concurrent_http_request ( )
184+ public async Task When_no_http_context_the_reset_flag_does_not_matter ( )
185+ {
186+ // Point 2: in the crontab background flow HttpContext is null when OnAuthenticate runs, so
187+ // SetUserIdentity takes the `|| HttpContext == null` branch and creates a fresh context
188+ // whether isResetHttpContext is true or false. Each case runs in its own ExecutionContext.
189+ var withFalse = await Task . Run ( ( ) =>
190+ {
191+ var accessor = new HttpContextAccessor { HttpContext = null } ;
192+ SetUserIdentity ( accessor , OperatorUid , isResetHttpContext : false ) ;
193+ return ( hasContext : accessor . HttpContext != null , uid : ReadUid ( accessor ) ) ;
194+ } ) ;
195+
196+ var withTrue = await Task . Run ( ( ) =>
197+ {
198+ var accessor = new HttpContextAccessor { HttpContext = null } ;
199+ SetUserIdentity ( accessor , OperatorUid , isResetHttpContext : true ) ;
200+ return ( hasContext : accessor . HttpContext != null , uid : ReadUid ( accessor ) ) ;
201+ } ) ;
202+
203+ Assert . True ( withFalse . hasContext ) ;
204+ Assert . True ( withTrue . hasContext ) ;
205+ Assert . Equal ( OperatorUid , withFalse . uid ) ;
206+ Assert . Equal ( withTrue . uid , withFalse . uid ) ; // identical outcome
207+ }
208+
209+ [ Fact ]
210+ public async Task Cron_identity_does_NOT_pollute_a_concurrent_http_request ( )
178211 {
179212 var accessor = new HttpContextAccessor { HttpContext = null } ;
180213
@@ -192,7 +225,9 @@ async Task CronJob()
192225 {
193226 for ( var i = 0 ; i < 20 ; i ++ )
194227 {
195- SetUserIdentity ( accessor , OperatorUid , isResetHttpContext : true ) ; // background default
228+ // shipped background behaviour: reset:false, but HttpContext is null in this flow so
229+ // a fresh context is created and never touches the request's context.
230+ SetUserIdentity ( accessor , OperatorUid , isResetHttpContext : false ) ;
196231 await Task . Delay ( 1 ) ;
197232 }
198233 }
0 commit comments