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
This commit is contained in:
113
context-propagation/docs/08-testing-contract.md
Normal file
113
context-propagation/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