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:
113
docs/08-testing-contract.md
Normal file
113
docs/08-testing-contract.md
Normal 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.
|
||||
Reference in New Issue
Block a user