1
0

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.
This commit is contained in:
2026-08-24 21:48:47 +05:30
committed by Claude
commit 9f950bffa9
28 changed files with 2009 additions and 0 deletions

113
docs/08-testing-contract.md Normal file
View File

@@ -0,0 +1,113 @@
# 8. Testing contract + edge-case index
[← Prev: Servlet filter persistence](07-servlet-filter-persistence.md)
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`](output/tests.txt).
## Running the tests
```bash
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](04-executor-wrapping.md) |
| `delegatingSecurityContextExecutorServicePropagatesAndCapturesIndependentlyPerTask` | Two tasks on a reused pool worker each keep their own context | [4](04-executor-wrapping.md) |
| `completableFutureDefaultExecutorLosesContext_delegatingExecutorPropagates` | Common `ForkJoinPool` loses context; a wrapped executor keeps it | [4](04-executor-wrapping.md) |
| `delegatingSecurityContextAsyncTaskExecutorPropagates` | `DelegatingSecurityContextAsyncTaskExecutor` propagates through `ThreadPoolTaskExecutor` | [4](04-executor-wrapping.md) |
| `reactiveContextSurvivesSchedulerHop_threadLocalDoesNot` | `ThreadLocal` fails across a `publishOn` hop; Reactor `Context` survives it | [5](05-reactive-context.md) |
| `reactiveGetProfileDefaultsToAnonymousWithNoUpstreamContext` | `getProfile()` degrades to `"Anonymous"`, does not error, with no context written | [5](05-reactive-context.md) |
| `delegatingSecurityContextTaskSchedulerCapturesPerScheduleCallNotAtConstruction` | Two `schedule()` calls on one wrapper capture two independent contexts | [6](06-scheduled-tasks.md) |
| `securityContextPersistenceFilterAutoSaves_holderFilterDoesNot` | The Security-6 filter swap changed save behavior, not load behavior | [7](07-servlet-filter-persistence.md) |
| `securityContextHolderFilterLoadsAnExistingSession` | `SecurityContextHolderFilter` still loads correctly from a prior save | [7](07-servlet-filter-persistence.md) |
| `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](01-inheritable-threadlocal.md) for the leak, [Chapter
4](04-executor-wrapping.md#the-edge-case-chapter-1-sets-up-and-this-chapter-resolves) 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](04-executor-wrapping.md).
- **`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](02-async-virtual-threads.md).
- **`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](03-structured-concurrency.md).
- **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](03-structured-concurrency.md).
- **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](05-reactive-context.md#edge-case-map-cannot-emit-null).
- **`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](05-reactive-context.md).
- **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](05-reactive-context.md).
- **`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](06-scheduled-tasks.md).
- **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](06-scheduled-tasks.md).
- **`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](07-servlet-filter-persistence.md).
- **`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](07-servlet-filter-persistence.md).
- **`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.