1
0
Files
spring-security-demo/docs/08-testing-contract.md
Ankur 9f950bffa9 Add every example from the post, plus edge cases, to the companion repo
Full companion repo for the ankurm.com post "Spring Security Context Propagation:
The Complete Guide" -- every code example the post discusses now has a corresponding
runnable, verified demo (JDK 25, Spring Security 7.1.1, Spring Boot 4.1.1 dependency
versions), not just the virtual-thread/structured-concurrency sections:

- Demo1PlainThreadLocal: InheritableThreadLocal across thread models (no Spring)
- Demo2AsyncVirtualThreads: @Async on a virtual-thread SimpleAsyncTaskExecutor
  (DelegatingSecurityContextExecutor vs ContextPropagatingTaskDecorator)
- Demo3StructuredConcurrency: StructuredTaskScope.fork() propagation
- Demo4ExecutorWrapping: DelegatingSecurityContextExecutorService/Executor/
  AsyncTaskExecutor on a classic pooled platform-thread executor -- the post's
  "Using @Async" / "Using ExecutorService" / "Using CompletableFuture" sections
- Demo5ReactiveContext: ReactiveSecurityContextHolder vs. ThreadLocal across a
  Reactor scheduler hop -- the post's WebFlux/getProfile() section
- Demo6ScheduledSystemIdentity: DelegatingSecurityContextTaskScheduler's actual
  per-call capture semantics (confirmed via bytecode before writing the demo) and
  the createSystemContext() pattern -- the post's scheduled-tasks section
- Demo7ServletFilterPersistence: SecurityContextHolderFilter (load-only) vs.
  SecurityContextPersistenceFilter (load+auto-save), against real filter instances
  and a real HttpSession -- the post's servlet-environment section
- SecurityContextPropagationContractTest: 10 JUnit tests pinning the above as
  assertions instead of printed lines, including a TestSecurityContextHolder-based
  test reproducing the post's own "Testing Security Context Propagation" section

Thirteen edge cases discovered along the way are indexed in docs/08 with links into
the chapter that reproduces each one -- a reused pool worker NOT leaking under the
Delegating* wrappers (unlike Demo1's InheritableThreadLocal), the common ForkJoinPool
trap, why there's no DelegatingSecurityContextStructuredTaskScope and never will be,
a real NullPointerException from Reactor's map() hit while writing the reactive test,
per-call (not per-construction) context capture in DelegatingSecurityContextTaskScheduler,
and the precise load-vs-save split between the two servlet filters, among others.

docs/01-08 are numbered, cross-linked chapters with prev/next navigation; README
indexes all demos, chapters, captured output, and the edge-case list. scripts/run-all.sh
regenerates every docs/output/*.txt and the test suite output from one command.
2026-08-24 16:49:30 +00:00

8.5 KiB

8. Testing contract + edge-case index

← Prev: Servlet filter persistence

Every chapter so far demonstrates a claim by printing it. This chapter pins the same claims as real JUnit 5 assertions (mvn test, SecurityContextPropagationContractTest, 10 tests) and collects, in one place, every edge case discovered while building this repository -- the things that would not survive a copy-paste of the post's original code samples, or that the post's prose states as fact and this repo now proves against a real run instead. Output in docs/output/tests.txt.

Running the tests

mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt
mvn test

The surefire plugin is configured with --enable-preview in pom.xml's argLine, matching the compiler configuration -- no extra flags needed on the command line.

What the ten tests pin

Test Claim it pins Chapter
rawThreadPoolExecutorLosesContext An unwrapped ThreadPoolExecutor loses SecurityContext 4
delegatingSecurityContextExecutorServicePropagatesAndCapturesIndependentlyPerTask Two tasks on a reused pool worker each keep their own context 4
completableFutureDefaultExecutorLosesContext_delegatingExecutorPropagates Common ForkJoinPool loses context; a wrapped executor keeps it 4
delegatingSecurityContextAsyncTaskExecutorPropagates DelegatingSecurityContextAsyncTaskExecutor propagates through ThreadPoolTaskExecutor 4
reactiveContextSurvivesSchedulerHop_threadLocalDoesNot ThreadLocal fails across a publishOn hop; Reactor Context survives it 5
reactiveGetProfileDefaultsToAnonymousWithNoUpstreamContext getProfile() degrades to "Anonymous", does not error, with no context written 5
delegatingSecurityContextTaskSchedulerCapturesPerScheduleCallNotAtConstruction Two schedule() calls on one wrapper capture two independent contexts 6
securityContextPersistenceFilterAutoSaves_holderFilterDoesNot The Security-6 filter swap changed save behavior, not load behavior 7
securityContextHolderFilterLoadsAnExistingSession SecurityContextHolderFilter still loads correctly from a prior save 7
testSecurityContextHolderIsTheSameHolderTestSecurityContextHolderWrites TestSecurityContextHolder and SecurityContextHolder share one underlying holder this chapter

The last test reproduces the post's own "Testing Security Context Propagation" section -- specifically AsyncServiceTest.testAsyncWithManualContext -- almost line for line: TestSecurityContextHolder.setAuthentication(...) sets the context the same way manual SecurityContextHolder.getContext().setAuthentication(...) does in the post's example, then a DelegatingSecurityContextExecutorService-wrapped task reads it back on a different thread and the assertion checks the result contains the test principal's name. The point worth knowing: TestSecurityContextHolder is not a separate mock holder that needs wiring -- Spring Security's default SecurityContextHolderStrategy is one strategy per JVM (per thread, under MODE_THREADLOCAL), and TestSecurityContextHolder writes through the same one production code reads from. @WithMockUser is a thin annotation-driven wrapper around the same mechanism, wired in by WithSecurityContextTestExecutionListener when tests run under a full Spring TestContext; this repo's tests use TestSecurityContextHolder directly instead, since none of the other demos need a Spring ApplicationContext and adding one just for this test would be the only place in the repository that did.

Edge-case index

Every edge case this repository actually reproduces, in one list. Each links to the chapter that runs it.

  • A reused pool worker keeps two different tasks' contexts separate under the Delegating* wrappers, unlike plain InheritableThreadLocal, which leaks the previous task's value onto a reused worker. See Chapter 1 for the leak, Chapter 4 for the fix proven independent-per-task.
  • CompletableFuture.supplyAsync(supplier) with no executor argument silently uses the common ForkJoinPool, which never propagates SecurityContext -- easy to miss because the one-argument overload compiles fine and works in every way except this one. See Chapter 4.
  • MODE_INHERITABLETHREADLOCAL is JVM-wide: turning it on to fix one virtual-thread executor also changes behavior for every other platform thread pool in the same process (JDBC housekeeping threads, a hand-rolled ThreadPoolExecutor, the common ForkJoinPool behind parallel streams) and leaks the active SecurityContext into background threads that were never meant to run as the current user. See Chapter 2.
  • StructuredTaskScope inherits ScopedValue bindings by specification and says nothing about ThreadLocal -- SecurityContextHolder gets nothing for free from a fork() call, which is easy to assume otherwise since the forked subtask is a fresh virtual thread, exactly the shape that makes MODE_INHERITABLETHREADLOCAL work elsewhere. See Chapter 3.
  • There is no DelegatingSecurityContextStructuredTaskScope and there will not be one, structurally: StructuredTaskScope does not implement Executor, so there is no execute(Runnable) seam for a Delegating* class to wrap. See Chapter 3.
  • Reactor's Mono.map() throws NullPointerException if the mapper returns null -- discovered writing this repo's own JUnit test for reactive context propagation. Diagnostic code that maps straight to SecurityContextHolder.getContext().getAuthentication() (which can legitimately be null) breaks on the first request with no authentication, unless the mapper returns a sentinel value or wraps in Optional instead. See Chapter 5.
  • ReactiveSecurityContextHolder.getContext() completes empty, it does not error, when no context was ever written upstream -- .defaultIfEmpty(...) is covering a real, reachable case (an anonymous request to a permitted endpoint), not defensive boilerplate. See Chapter 5.
  • A ThreadLocal write survives inside one operator but not across a publishOn hop to a different Scheduler -- proven as a same-chain before/after comparison, not two unrelated claims. See Chapter 5.
  • DelegatingSecurityContextTaskScheduler's single-argument constructor captures SecurityContextHolder.getContext() fresh on every schedule() call, not once when the wrapper object is built -- confirmed by reading the class's bytecode before writing the demo, then proving it against the real class. Two schedule() calls on the same wrapper, with the caller's context changed in between, do not see each other's value. See Chapter 6.
  • A synthetic SYSTEM principal is not "anonymous" to AuthenticationTrustResolver -- authorization rules keyed on isAnonymous() will not match it, which matters for anything gated by .anonymous() in a SecurityFilterChain. See Chapter 6.
  • SecurityContextHolderFilter (Security 6+ default) only ever loads -- it has no code path that calls SecurityContextRepository.saveContext(...) at all, confirmed by running it against a real session and observing nothing gets written, then confirming the load side still works correctly against a session seeded by an earlier explicit save. See Chapter 7.
  • SecurityContextHolder.getContext() never returns null -- an unauthenticated request gets an empty SecurityContext object whose getAuthentication() is null, not a null context. Code that checks context == null to detect "nobody's authenticated" is checking the wrong condition. See Chapter 7.
  • TestSecurityContextHolder and production SecurityContextHolder read and write the same underlying strategy -- there's no separate mock state to keep in sync. See this chapter, above.