Skip to content

Add E2E release-gate harness with 8 scenarios - #8

Merged
xavierjohn merged 2 commits into
mainfrom
feat/e2e-harness
Jun 6, 2026
Merged

Add E2E release-gate harness with 8 scenarios#8
xavierjohn merged 2 commits into
mainfrom
feat/e2e-harness

Conversation

@xavierjohn

Copy link
Copy Markdown
Owner

What

Adds the P4 E2E validation harness at examples/E2EHarness/ — 8 release-gate scenarios that CI runs on every push to main to gate the next NuGet publish. Closes the p4-e2e-validation-harness task.

Architecture

In-process TestServer instances wired through a custom IForwarderHttpClientFactory so YARP routes outbound requests through the destination's HttpMessageHandler instead 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 /probe endpoint.

The 8 scenarios

# Scenario P4 invariant covered
1 HappyPath_GatewayMintsActorRoundtripsToDownstream Baseline — full Actor roundtrips end-to-end
2 NoActor_UpstreamAuthorizationHeaderCleared #11 No-actor Authorization clear (cryptographically-valid leaked token used so regression → 200)
3 CrossAudienceMismatch_DownstreamRejects401 JwtBearer transport-layer ValidAudience pin (actor-provider ExpectedAudience disabled in this scenario to isolate the check)
4 SentinelStripped_CountMismatchFailsClosed #6 Sentinel + count claims (forbidden side) — deny-overrides-allow integrity
5 ContractVersionSentinelMissing_FailsClosed #6 Sentinel + count claims (missing sentinel)
6 PermissionsCountMismatch_FailsClosed #6 Sentinel + count claims (allow side)
7 StrictClaimShape_CommaJoinedPermissionsRejected Strict claim shape — defends against gateway-side bug of comma-joining a list
8 ExpectedIssuerMismatch_ActorProviderFailsClosed Actor-provider runtime ExpectedIssuer defense-in-depth (vs JwtBearer ValidIssuer)

Invariants NOT covered by this harness (and where they ARE covered): see examples/E2EHarness/README.md mapping table.

Verification

  • dotnet build Trellis.Microservices.slnx -c Release — 0 warnings, 0 errors
  • dotnet test231/231 pass (was 223; +8 new scenarios)
  • ✅ Doc lint clean
  • ✅ Each scenario runs in < 250ms; full harness < 2 seconds

GPT-5.5 code review (pre-commit)

3 Tier-1 findings, all applied:

# Finding Fix
T1.1 Scenario 2 false-positive risk: any 401 would pass even if the gateway leaked the upstream Authorization header (downstream JwtBearer would reject random garbage anyway) Use a leaked-but-cryptographically-valid token. If gateway regresses → 200; only a correctly-clearing gateway → 401.
T1.2 Scenario 3 was double-rejected (both JwtBearer ValidAudience AND actor-provider ExpectedAudience). Override configureActor: o => o.ExpectedAudience = "" to isolate the JwtBearer check.
T1.3 README claim that Scenarios 1-3 prove TryAllIssuerSigningKeys=false works end-to-end was too strong — harness configures one signing key, so wrong-kid fallback can't trigger. Narrowed the README to say the fixture sets the flag; a dedicated multi-key-ring scenario is noted as future work.

Reviewer focus

  • Scenario 2's leaked-but-valid token approach — confirm the rationale (a regression would actually fail this test) holds against Trellis.Yarp/src/TrellisActorForwardingTransformProvider.cs.
  • README's invariant-coverage mapping table — confirm each Covered by cell is accurate against the actual test surface.

🤖 Generated with GitHub Copilot CLI

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System.Collections.Hashtable[3367733906]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +90 to +94
ValidAudience = expectedAudience,
ValidateLifetime = true,
RequireSignedTokens = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = trustedSigningKey,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System.Collections.Hashtable[3367733913]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +56 to +62
/// 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>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System.Collections.Hashtable[3367733920]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +30 to +37
<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>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System.Collections.Hashtable[3367733929]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.

/// <para>The scenarios divide into two categories:</para>
/// <list type="bullet">
/// <item><description>
/// <b>Gateway-to-downstream flow</b> (HappyPath, NoActor, CrossAudience, KeyRotation):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System.Collections.Hashtable[3367751991]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +24 to +27
/// 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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System.Collections.Hashtable[3367752018]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread examples/E2EHarness/HarnessFixtures.cs Outdated
Comment on lines +25 to +30
/// <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>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System.Collections.Hashtable[3367752042]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread examples/E2EHarness/README.md Outdated
/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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System.Collections.Hashtable[3367752081]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread examples/E2EHarness/README.md Outdated
| 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. |

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System.Collections.Hashtable[3367752126]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread examples/E2EHarness/GatewayHarness.cs Outdated
Comment on lines +85 to +89
private sealed class TestServerForwarderHttpClientFactory(HttpMessageHandler handler) : IForwarderHttpClientFactory
{
public HttpMessageInvoker CreateClient(ForwarderHttpClientContext context)
=> new(handler, disposeHandler: false);
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System.Collections.Hashtable[3367752173]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread CHANGELOG.md
- **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.cTrellis.Yarp move + Trellis.Microservices.AspNetCore carve-out
## 0.1-alpha.dCookbook Recipes 1+2 inlined + UseTrellisInternalJwtActor references cleaned up

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

System.Collections.Hashtable[3367752225]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@xavierjohn
xavierjohn merged commit c989599 into main Jun 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants