1
0
Files
spring-security-demo/context-propagation/docs/08-testing-contract.md
asmhatre 5e9e7f1b12 Split into per-article modules and add the method-security module
Moves the existing virtual-thread/context-propagation project into
context-propagation/ and adds method-security/ for the Spring Security 7
method-security article: nine runnable demos, fourteen assertions, and every
transcript the article quotes, regenerated by scripts/run-all.sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSrsDSRKVsY588yFiMJMo9
2026-08-25 02:01:29 +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.