Add E2E release-gate harness with 8 scenarios - #8
Conversation
Implements p4-e2e-validation-harness task. The harness lives at examples/E2EHarness/ and contains 8 release-gate scenarios that CI runs on every push to main; if any fail the publish workflow does not run. Architecture ------------ In-process TestServer instances wired through a custom IForwarderHttpClientFactory so YARP routes outbound requests through the destination TestServer's HttpMessageHandler instead of the network. No docker, no sockets, deterministic across machines, < 5 seconds in CI. For attack-token scenarios (sentinel-stripped, count-mismatch, comma-joined, etc.) the test bypasses the gateway and hand-crafts a malformed JWT signed with the harness's RSA key, POSTed directly to the destination's /probe endpoint. The 8 scenarios --------------- 1. HappyPath_GatewayMintsActorRoundtripsToDownstream 2. NoActor_UpstreamAuthorizationHeaderCleared 3. CrossAudienceMismatch_DownstreamRejects401 4. SentinelStripped_CountMismatchFailsClosed 5. ContractVersionSentinelMissing_FailsClosed 6. PermissionsCountMismatch_FailsClosed 7. StrictClaimShape_CommaJoinedPermissionsRejected 8. ExpectedIssuerMismatch_ActorProviderFailsClosed GPT-5.5 code review (pre-commit) --------------------------------- Three Tier-1 findings, all applied: T1.1 - Scenario 2 was a false-positive risk: any 401 would pass, even if the gateway leaked the upstream Authorization header and the downstream JwtBearer just rejected the random garbage token. Fix: use a leaked-but- cryptographically-valid token (signed with trusted key, matching iss/aud/ contract claims). Now if the gateway regresses and forwards the header, the response is 200; only a correctly-clearing gateway produces 401. T1.2 - Scenario 3 claimed to prove JwtBearer ValidAudience but the fixture's actor-provider also had ExpectedAudience set to the same value, so an audience mismatch was double-rejected. Fix: override the actor-provider ExpectedAudience to empty in Scenario 3 to isolate the assertion to the JwtBearer transport-layer check. T1.3 - README claimed Scenarios 1-3 prove TryAllIssuerSigningKeys=false works, but the harness only configures one signing key so the wrong-kid fallback path can't trigger. Fix: narrowed the README to say the fixture sets the flag and notes a dedicated multi-key-ring scenario would be needed to truly exercise the invariant (deferred for now; the gateway- side rotation-ring is covered by Trellis.Yarp/tests/). Build + test ------------ - dotnet build Trellis.Microservices.slnx -c Release: 0 warnings, 0 errors - dotnet test: 231 total / 231 pass / 0 fail (was 223; +8 new scenarios) - Doc lint clean (4 markdown files in api_reference/) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a new end-to-end “release gate” test harness under examples/E2EHarness/ that runs 8 security-focused scenarios against in-process gateway + downstream TestServer instances to validate the Trellis internal-JWT contract and fail-closed posture before NuGet publishes.
Changes:
- Introduces 8 E2E release-gate scenarios (gateway→downstream and downstream-only “attack token” cases).
- Adds shared harness infrastructure (in-process destination + gateway hosts, in-process YARP forwarding, direct JWT minting for negative cases) plus harness documentation.
- Wires the harness into the solution and documents it in the changelog.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| Trellis.Microservices.slnx | Adds the examples/E2EHarness test project and README to the solution structure. |
| examples/E2EHarness/ReleaseGateScenarios.cs | Implements the 8 release-gate scenarios that assert end-to-end and fail-closed behavior. |
| examples/E2EHarness/HarnessFixtures.cs | Provides shared helpers for destination TestServer configuration and handcrafted JWT minting/probing. |
| examples/E2EHarness/GatewayHarness.cs | Builds an in-process YARP gateway TestServer and routes to the destination without network I/O. |
| examples/E2EHarness/E2EHarness.Tests.csproj | Adds the harness test project and its dependencies/references. |
| examples/E2EHarness/README.md | Documents purpose, architecture, scenarios, and invariant coverage mapping. |
| CHANGELOG.md | Records the addition of the E2E release-gate harness in Unreleased notes. |
| /// <para>The scenarios divide into two categories:</para> | ||
| /// <list type="bullet"> | ||
| /// <item><description> | ||
| /// <b>Gateway-to-downstream flow</b> (HappyPath, NoActor, CrossAudience, KeyRotation): |
There was a problem hiding this comment.
System.Collections.Hashtable[3367733906]
There was a problem hiding this comment.
Apology: my prior reply on this thread was a script error that posted 'System.Collections.Hashtable[...]' instead of the actual text. Real reply: Fixed in a04fa16. The class XmlDoc now lists the actual 8 scenarios by category (HappyPath/NoActor/CrossAudience in the gateway flow group; the rest in the attack-token group). The phantom KeyRotation entry is gone.
| ValidAudience = expectedAudience, | ||
| ValidateLifetime = true, | ||
| RequireSignedTokens = true, | ||
| ValidateIssuerSigningKey = true, | ||
| IssuerSigningKey = trustedSigningKey, |
There was a problem hiding this comment.
System.Collections.Hashtable[3367733913]
There was a problem hiding this comment.
Apology: my prior reply on this thread was a script error that posted 'System.Collections.Hashtable[...]' instead of the actual text. Real reply: Fixed in a04fa16. Added RequireExpirationTime = true to the TokenValidationParameters. Now aligned with the canonical Recipe 1 strict profile.
| /// The destination's <c>/probe</c> endpoint requires authentication and dumps the | ||
| /// hydrated <see cref="Actor"/> in the response body as JSON. Scenarios assert | ||
| /// either: | ||
| /// <list type="bullet"> | ||
| /// <item><description>HTTP 200 + expected actor shape (happy path / contract-conformant token),</description></item> | ||
| /// <item><description>HTTP 401 (downstream fail-closed posture — actor provider returned <c>Maybe.None</c>, mediator pipeline 401s), or</description></item> | ||
| /// <item><description>HTTP 401 from <c>AddJwtBearer</c> itself (signature / aud / iss / exp failure before the actor provider runs).</description></item> |
There was a problem hiding this comment.
System.Collections.Hashtable[3367733920]
There was a problem hiding this comment.
Apology: my prior reply on this thread was a script error that posted 'System.Collections.Hashtable[...]' instead of the actual text. Real reply: Fixed in a04fa16. The /probe endpoint handler itself returns 401 on Maybe.None (no mediator in this harness). Comment updated to describe what actually happens.
| <ItemGroup> | ||
| <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> | ||
| <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" /> | ||
| <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" /> | ||
| <PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" /> | ||
| <PackageReference Include="Microsoft.IdentityModel.Tokens" /> | ||
| <PackageReference Include="Yarp.ReverseProxy" /> | ||
| </ItemGroup> |
There was a problem hiding this comment.
System.Collections.Hashtable[3367733929]
There was a problem hiding this comment.
Apology: my prior reply on this thread was a script error that posted 'System.Collections.Hashtable[...]' instead of the actual text. Real reply: Fixed in a04fa16. Removed the unused Microsoft.Extensions.TimeProvider.Testing PackageReference.
| /// <para>The scenarios divide into two categories:</para> | ||
| /// <list type="bullet"> | ||
| /// <item><description> | ||
| /// <b>Gateway-to-downstream flow</b> (HappyPath, NoActor, CrossAudience, KeyRotation): |
There was a problem hiding this comment.
System.Collections.Hashtable[3367751991]
There was a problem hiding this comment.
Apology: my prior reply on this thread was a script error that posted 'System.Collections.Hashtable[...]' instead of the actual text. Real reply: Fixed in a04fa16 in the same edit as the related KeyRotation finding (these review comments came in for the same spot).
| /// ExpectedIssuer*): the test hand-crafts a malformed JWT signed with the trusted | ||
| /// key and POSTs it to the destination's /probe endpoint, asserting the downstream | ||
| /// fails closed (HTTP 401). These bypass the gateway because the attack is in the | ||
| /// shape of the token, not in the gateway's behavior. |
There was a problem hiding this comment.
System.Collections.Hashtable[3367752018]
There was a problem hiding this comment.
Apology: my prior reply on this thread was a script error that posted 'System.Collections.Hashtable[...]' instead of the actual text. Real reply: Fixed in a04fa16. Both class summaries (ReleaseGateScenarios.cs and HarnessFixtures.cs) now say GET /probe to match the actual client.GetAsync call in HarnessFixtures.ProbeAsync.
| /// <para>Each scenario wires: | ||
| /// gateway (TestServer with AddTrellisActorForwarding) → destination (TestServer | ||
| /// with AddJwtBearer + AddTrellisInternalJwtActorProvider). For "attack" scenarios | ||
| /// that need a hand-crafted JWT (e.g. sentinel-stripped, count-mismatch), the test | ||
| /// skips the gateway, mints its own JWT signed with the harness's RSA key, and | ||
| /// POSTs it directly to the destination.</para> |
There was a problem hiding this comment.
System.Collections.Hashtable[3367752042]
There was a problem hiding this comment.
Apology: my prior reply on this thread was a script error that posted 'System.Collections.Hashtable[...]' instead of the actual text. Real reply: Fixed in a04fa16 in the same edit as the matching ReleaseGateScenarios.cs comment — both now describe GET /probe + Authorization Bearer header.
| /probe endpoint → ProbeResponse { id, permissions, forbiddenPermissions, attributes } | ||
| ``` | ||
|
|
||
| For "attack token" scenarios (sentinel-stripped, count-mismatch, comma-joined shape, etc.) the harness skips the gateway entirely: the test hand-crafts a malformed JWT signed with the harness's RSA key and POSTs it directly to the destination. This isolates the contract-integrity check on the consumer side. |
There was a problem hiding this comment.
System.Collections.Hashtable[3367752081]
There was a problem hiding this comment.
Apology: my prior reply on this thread was a script error that posted 'System.Collections.Hashtable[...]' instead of the actual text. Real reply: Fixed in a04fa16. README now says 'sends a GET /probe request directly to the destination with Authorization: Bearer '.
| | No-actor `Authorization` clear | **This harness, Scenario 2** | | ||
| | Audit-log redaction | `Trellis.Yarp/tests/TrellisActorForwardingRequestTransformTests.cs` | | ||
| | Strict claim shape | **This harness, Scenario 7** | | ||
| | Mandatory consumer flags (`MapInboundClaims=false`, `TryAllIssuerSigningKeys=false`) | **This harness fixture configures both (`HarnessFixtures.StartDestinationAsync`)** as part of every gateway/downstream scenario; their effects are exercised by Scenarios 1-3 indirectly (the contract claim-name resolution + signing-key resolution paths only succeed when the flags are correctly set). The wrong-`kid` / multi-key-ring fallback path that `TryAllIssuerSigningKeys=false` specifically protects against is NOT exercised end-to-end by these 8 scenarios — that invariant is covered by `Trellis.Yarp/tests/TrellisDiscoveryEndpointTests.cs` (gateway side) and would warrant a dedicated future scenario here against a JwtBearer instance configured with a multi-key `IssuerSigningKeys` collection. | |
There was a problem hiding this comment.
System.Collections.Hashtable[3367752126]
There was a problem hiding this comment.
Apology: my prior reply on this thread was a script error that posted 'System.Collections.Hashtable[...]' instead of the actual text. Real reply: Fixed in a04fa16. Narrowed further: the row now says the fixture configures both flags consistent with Recipe 1 but does NOT prove either's protective effect (sub short-long fallback works with MapInboundClaims=true; wrong-kid fallback isn't exercised with a single-key fixture). Acknowledges the gateway-side rotation-ring is covered elsewhere and notes a multi-key-ring scenario as future work.
| private sealed class TestServerForwarderHttpClientFactory(HttpMessageHandler handler) : IForwarderHttpClientFactory | ||
| { | ||
| public HttpMessageInvoker CreateClient(ForwarderHttpClientContext context) | ||
| => new(handler, disposeHandler: false); | ||
| } |
There was a problem hiding this comment.
System.Collections.Hashtable[3367752173]
There was a problem hiding this comment.
Apology: my prior reply on this thread was a script error that posted 'System.Collections.Hashtable[...]' instead of the actual text. Real reply: Fixed in a04fa16. Made TestServerForwarderHttpClientFactory implement IDisposable so DI disposes the handler when the singleton's owning host disposes. Per-scenario teardown now releases the handler cleanly.
| - **E2E release-gate harness** in `examples/E2EHarness/`. 8 scenarios that validate the gateway → JWT → downstream flow end-to-end, plus the consumer-side fail-closed posture against attack tokens (sentinel-stripped, count-mismatched, comma-joined, expected-issuer-mismatched, contract-version-missing). Runs against in-process `TestServer` instances via a custom `IForwarderHttpClientFactory` (no docker, no sockets, < 5 seconds in CI). Maps directly to the P4 invariants in `.github/copilot-instructions.md` "never regress" table. Designed as the gate that release pipelines run before publishing new preview NuGets. | ||
|
|
||
| ## 0.1-alpha.c — Trellis.Yarp move + Trellis.Microservices.AspNetCore carve-out | ||
| ## 0.1-alpha.d — Cookbook Recipes 1+2 inlined + UseTrellisInternalJwtActor references cleaned up |
There was a problem hiding this comment.
System.Collections.Hashtable[3367752225]
There was a problem hiding this comment.
Apology: my prior reply on this thread was a script error that posted 'System.Collections.Hashtable[...]' instead of the actual text. Real reply: Fixed in a04fa16 via CHANGELOG reconstruction. Root cause: a prior edit during PR #7 ate the 0.1-alpha.b heading and mis-attributed its body to 0.1-alpha.c; PR #8 compounded it. Rebuilt the full version history from git: Unreleased = E2E harness (this PR); 0.1-alpha.d = recipes inlining; 0.1-alpha.c = Yarp+AspNetCore carve-out (recovered from PR #3's merge commit); 0.1-alpha.b = Abstractions; 0.1-alpha.a = bootstrap. Each heading now matches its body.
…CHANGELOG
Round-1 Copilot reviewer findings (all 11 applied):
1+5. ReleaseGateScenarios.cs:16 - class XmlDoc listed a "KeyRotation"
gateway-to-downstream scenario that doesn't exist. Updated the
docstring to list the actual 8 scenarios by category.
2. HarnessFixtures.cs:94 - fixture claimed to use "strict cookbook
Recipe 1 profile" but missed RequireExpirationTime=true. Added.
3. HarnessFixtures.cs:62 - remarks said "mediator pipeline 401s" but
this harness has no mediator. The /probe endpoint handler itself
returns 401 on Maybe.None. Comment updated.
4. E2EHarness.Tests.csproj:37 - removed unused
Microsoft.Extensions.TimeProvider.Testing PackageReference
(nothing in the project uses FakeTimeProvider).
6+7+8. POST -> GET across ReleaseGateScenarios.cs:27, HarnessFixtures.cs:30,
README.md:27. The harness actually uses GET /probe with
Authorization: Bearer header (per HarnessFixtures.ProbeAsync); the
"POST" wording in the docs was inaccurate.
9. README.md:69 - P4-invariant mapping row for MapInboundClaims=false /
TryAllIssuerSigningKeys=false was still over-claiming. Narrowed
further: the row now says the fixture configures both consistent
with Recipe 1 but does NOT prove either's protective effect (the
actor provider has explicit sub short-long fallback that lets the
contract work even with MapInboundClaims=true; the wrong-kid
fallback path is not exercised with the single-key fixture).
10. GatewayHarness.cs:89 - the TestServerForwarderHttpClientFactory
captures an HttpMessageHandler but never disposes it (handler leak
across larger/looped runs). Made the factory IDisposable so DI
disposes the handler when the host disposes.
11. CHANGELOG.md:11 - section heading "0.1-alpha.d - Cookbook Recipes
1+2 inlined" had bullets describing the Abstractions package. Root
cause: a prior edit during PR #7 ate the 0.1-alpha.b heading and
mis-attributed 0.1-alpha.b's bullets to 0.1-alpha.c, then PR #8
compounded the damage by renaming 0.1-alpha.c -> 0.1-alpha.d.
Reconstructed the full CHANGELOG from git history. Structure now:
## Unreleased - E2E harness (this PR)
## 0.1-alpha.d - Recipes inlining + slot-ref cleanup (PR #7)
## 0.1-alpha.c - Yarp move + AspNetCore carve-out (PR #3)
## 0.1-alpha.b - Abstractions package (PR #2)
## 0.1-alpha.a - Initial bootstrap (PR #1)
The 0.1-alpha.d body was recovered from origin/main's Unreleased
section (post-PR-#7-merge); the 0.1-alpha.c body was recovered from
PR #3's merge-commit CHANGELOG state via git show.
Build + test + lint: 0 warnings, 0 errors; 231/231 pass; doc-lint clean.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
What
Adds the P4 E2E validation harness at
examples/E2EHarness/— 8 release-gate scenarios that CI runs on every push tomainto gate the next NuGet publish. Closes thep4-e2e-validation-harnesstask.Architecture
In-process
TestServerinstances wired through a customIForwarderHttpClientFactoryso YARP routes outbound requests through the destination'sHttpMessageHandlerinstead of the network. No docker, no sockets, < 5 seconds in CI. For "attack-token" scenarios that need a hand-crafted malformed JWT, the test bypasses the gateway and POSTs the token directly to the destination's/probeendpoint.The 8 scenarios
HappyPath_GatewayMintsActorRoundtripsToDownstreamActorroundtrips end-to-endNoActor_UpstreamAuthorizationHeaderClearedAuthorizationclear (cryptographically-valid leaked token used so regression → 200)CrossAudienceMismatch_DownstreamRejects401ValidAudiencepin (actor-providerExpectedAudiencedisabled in this scenario to isolate the check)SentinelStripped_CountMismatchFailsClosedContractVersionSentinelMissing_FailsClosedPermissionsCountMismatch_FailsClosedStrictClaimShape_CommaJoinedPermissionsRejectedExpectedIssuerMismatch_ActorProviderFailsClosedExpectedIssuerdefense-in-depth (vs JwtBearerValidIssuer)Invariants NOT covered by this harness (and where they ARE covered): see
examples/E2EHarness/README.mdmapping table.Verification
dotnet build Trellis.Microservices.slnx -c Release— 0 warnings, 0 errorsdotnet test— 231/231 pass (was 223; +8 new scenarios)GPT-5.5 code review (pre-commit)
3 Tier-1 findings, all applied:
configureActor: o => o.ExpectedAudience = ""to isolate the JwtBearer check.TryAllIssuerSigningKeys=falseworks end-to-end was too strong — harness configures one signing key, so wrong-kid fallback can't trigger.Reviewer focus
Trellis.Yarp/src/TrellisActorForwardingTransformProvider.cs.Covered bycell is accurate against the actual test surface.🤖 Generated with GitHub Copilot CLI