# 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.