Assert the OpenAPI document declares bearer auth on protected paths #80

Merged
rob merged 2 commits from feat/openapi-bearer-assertion into main 2026-08-06 04:29:27 +00:00
Owner

Closes task 146. Adds OpenApiDocumentTests.GetOpenApiDocument_EveryShippedEndpoint_DeclaresSecurityExactlyWhenItsOwnMetadataRequiresAuthorisation, which enumerates every real RouteEndpoint the shipped API maps (via EndpointDataSource, the same mechanism GroupScopedEndpointAuthorisationTests already uses) and compares each one's own IAuthorizeData/IAllowAnonymous metadata against whether the generated document declares the bearer security requirement on the matching path/method.

Verified against both mutations, then reverted: AuthenticationSecurityRequirementTransformer forced to emit nothing reddens the new test (and the existing test-only-endpoint test); forced to apply the requirement everywhere reddens the new test and the three existing negative-case tests.

On the existing hardcoded path list (GetOpenApiDocument_TestsMapTheirOwnRoutes_DescribesOnlyTheApisOwnEndpoints): left it as-is. Deriving its expected path set from the same EndpointDataSource enumeration would make it tautological — a test route added directly to PlaceMark.Api.Program.cs by accident (rather than through a test project's IStartupFilter, which is the whole point of that test) would appear in both the derived expectation and the document, and the test would pass regardless. The hardcoded list is what actually catches that; the new test's derived approach only works because its assertion is about metadata agreement, not endpoint membership.

No new ADR — this extends an existing, established test pattern rather than deciding anything new.

Closes task 146. Adds `OpenApiDocumentTests.GetOpenApiDocument_EveryShippedEndpoint_DeclaresSecurityExactlyWhenItsOwnMetadataRequiresAuthorisation`, which enumerates every real `RouteEndpoint` the shipped API maps (via `EndpointDataSource`, the same mechanism `GroupScopedEndpointAuthorisationTests` already uses) and compares each one's own `IAuthorizeData`/`IAllowAnonymous` metadata against whether the generated document declares the bearer security requirement on the matching path/method. Verified against both mutations, then reverted: `AuthenticationSecurityRequirementTransformer` forced to emit nothing reddens the new test (and the existing test-only-endpoint test); forced to apply the requirement everywhere reddens the new test and the three existing negative-case tests. On the existing hardcoded path list (`GetOpenApiDocument_TestsMapTheirOwnRoutes_DescribesOnlyTheApisOwnEndpoints`): left it as-is. Deriving its expected path set from the same `EndpointDataSource` enumeration would make it tautological — a test route added directly to `PlaceMark.Api.Program.cs` by accident (rather than through a test project's `IStartupFilter`, which is the whole point of that test) would appear in both the derived expectation and the document, and the test would pass regardless. The hardcoded list is what actually catches that; the new test's derived approach only works because its assertion is about metadata *agreement*, not endpoint *membership*. No new ADR — this extends an existing, established test pattern rather than deciding anything new.
Assert the OpenAPI document declares bearer auth on protected paths
All checks were successful
CI / build (pull_request) Successful in 2m26s
f27c28e451
rob left a comment

Verdict: mergeable

Circularity. Not trivially circular. The new test independently retrieves metadata via EndpointDataSource/RouteEndpoint.Metadata (not by calling the transformer, which reads context.Description.ActionDescriptor.EndpointMetadata) and compares the result to a real generated document. I reproduced both reported mutations (transformer forced to emit nothing / emit unconditionally) and both redden the new test, confirming it's a genuine end-to-end check that a route's document entry tracks its actual metadata.

There is a real, narrower circularity though: the requiresAuthorization predicate in the test (IAuthorizeData.Any() && !IAllowAnonymous.Any()) is a hand-copied duplicate of the transformer's own predicate. I mutated the transformer to drop the !IAllowAnonymous half (over-declaring on an endpoint carrying both) and the new whole-document test stayed green — only the pre-existing GetOpenApiDocument_EndpointRequiringAuthorizationButAllowingAnonymous_DoesNotDeclareTheSecurityRequirement test (hardcoded, independent oracle against a purpose-built route) caught it. This isn't a defect introduced by this PR: no shipped endpoint currently combines RequireAuthorization with AllowAnonymous, and ADR-0034 already documents this exact gap and accepts it, proving the precedence rule only via a test-only endpoint. But the new test's doc comment ("the same test AuthenticationSecurityRequirementTransformer itself makes") overstates what it covers — it verifies membership parity (protected vs. not), not the precedence rule between the two attribute types. Worth a one-line comment adjustment so a future reader doesn't assume this test would catch a precedence regression; not blocking.

Negative assertion. expectations.ShouldContain(expectation => !expectation.RequiresAuthorization) only proves the enumeration contains some anonymous route — it doesn't pin down which ones must stay anonymous. Because the expected value is derived from the same live metadata the document is generated from, this test (and the whole-document mechanism generally) cannot catch a business-logic regression where an anonymous-by-design route (e.g. /api/auth/login) is accidentally given .RequireAuthorization() — metadata and document would still agree with each other. /health/live has an independent, hardcoded oracle elsewhere in this file (GetOpenApiDocument_UnprotectedProbe_RequiresTheSchemeOnNoOperation); login/register/refresh/logout/OIDC challenge/callback/redeem do not, here or (as far as I checked) anywhere else. Not this PR's job to add that (it's a runtime-behaviour concern, not a doc/reality-parity one), but worth being clear-eyed that "every shipped endpoint" coverage here means doc-vs-metadata agreement, not doc-vs-intent.

Enumeration. Verified directly (dumped every RouteEndpoint from a live WebApplicationFactory): the only endpoints in EndpointDataSource are the 32 real API operations plus /openapi/{documentName}.json, which is excluded. Swagger UI's static assets are served by middleware, not RouteEndpoints, so they never enter the enumeration or the document's paths — no other exclusion is needed and none is silently missing.

Precedence semantics. IAllowAnonymous wins over IAuthorizeData regardless of registration order — the transformer's doc comment states this correctly and it matches the framework's actual short-circuit behaviour in AuthorizationMiddleware. The test's predicate encodes the same (correct) rule.

No new ADR. Agree — this reads endpoint metadata via EndpointDataSource the same way GroupScopedEndpointAuthorisationTests already does, and asserts a document/metadata relationship ADR-0034 already decided and partially proved. Nothing here is a new architectural decision.

Hardcoded path list left alone. Agree with the reasoning. A derived expected-path-set would only ever equal the document's own paths by construction if both were built from the same EndpointDataSource walk — exactly the "route added directly to Program.cs" failure mode the existing test exists to catch would then pass unnoticed. Keeping it hardcoded is correct.

Build and full PlaceMark.Api.Tests suite (534 tests) clean on 10.0.100; CI green on f27c28e.

Verdict: mergeable **Circularity.** Not trivially circular. The new test independently retrieves metadata via `EndpointDataSource`/`RouteEndpoint.Metadata` (not by calling the transformer, which reads `context.Description.ActionDescriptor.EndpointMetadata`) and compares the result to a real generated document. I reproduced both reported mutations (transformer forced to emit nothing / emit unconditionally) and both redden the new test, confirming it's a genuine end-to-end check that a route's document entry tracks its actual metadata. There is a real, narrower circularity though: the `requiresAuthorization` predicate in the test (`IAuthorizeData.Any() && !IAllowAnonymous.Any()`) is a hand-copied duplicate of the transformer's own predicate. I mutated the transformer to drop the `!IAllowAnonymous` half (over-declaring on an endpoint carrying both) and the new whole-document test stayed green — only the pre-existing `GetOpenApiDocument_EndpointRequiringAuthorizationButAllowingAnonymous_DoesNotDeclareTheSecurityRequirement` test (hardcoded, independent oracle against a purpose-built route) caught it. This isn't a defect introduced by this PR: no shipped endpoint currently combines `RequireAuthorization` with `AllowAnonymous`, and ADR-0034 already documents this exact gap and accepts it, proving the precedence rule only via a test-only endpoint. But the new test's doc comment ("the same test `AuthenticationSecurityRequirementTransformer` itself makes") overstates what it covers — it verifies membership parity (protected vs. not), not the precedence rule between the two attribute types. Worth a one-line comment adjustment so a future reader doesn't assume this test would catch a precedence regression; not blocking. **Negative assertion.** `expectations.ShouldContain(expectation => !expectation.RequiresAuthorization)` only proves the enumeration contains *some* anonymous route — it doesn't pin down *which* ones must stay anonymous. Because the expected value is derived from the same live metadata the document is generated from, this test (and the whole-document mechanism generally) cannot catch a business-logic regression where an anonymous-by-design route (e.g. `/api/auth/login`) is accidentally given `.RequireAuthorization()` — metadata and document would still agree with each other. `/health/live` has an independent, hardcoded oracle elsewhere in this file (`GetOpenApiDocument_UnprotectedProbe_RequiresTheSchemeOnNoOperation`); login/register/refresh/logout/OIDC challenge/callback/redeem do not, here or (as far as I checked) anywhere else. Not this PR's job to add that (it's a runtime-behaviour concern, not a doc/reality-parity one), but worth being clear-eyed that "every shipped endpoint" coverage here means doc-vs-metadata agreement, not doc-vs-intent. **Enumeration.** Verified directly (dumped every `RouteEndpoint` from a live `WebApplicationFactory`): the only endpoints in `EndpointDataSource` are the 32 real API operations plus `/openapi/{documentName}.json`, which is excluded. Swagger UI's static assets are served by middleware, not `RouteEndpoint`s, so they never enter the enumeration or the document's `paths` — no other exclusion is needed and none is silently missing. **Precedence semantics.** `IAllowAnonymous` wins over `IAuthorizeData` regardless of registration order — the transformer's doc comment states this correctly and it matches the framework's actual short-circuit behaviour in `AuthorizationMiddleware`. The test's predicate encodes the same (correct) rule. **No new ADR.** Agree — this reads endpoint metadata via `EndpointDataSource` the same way `GroupScopedEndpointAuthorisationTests` already does, and asserts a document/metadata relationship ADR-0034 already decided and partially proved. Nothing here is a new architectural decision. **Hardcoded path list left alone.** Agree with the reasoning. A derived expected-path-set would only ever equal the document's own paths by construction if both were built from the same `EndpointDataSource` walk — exactly the "route added directly to `Program.cs`" failure mode the existing test exists to catch would then pass unnoticed. Keeping it hardcoded is correct. Build and full `PlaceMark.Api.Tests` suite (534 tests) clean on 10.0.100; CI green on f27c28e.
Correct the new test's doc comment to state its actual coverage
All checks were successful
CI / build (pull_request) Successful in 2m28s
912ff44980
Author
Owner

Rewrote the new test's doc comment (912ff44) to state what it actually proves — membership parity, mutation-verified both ways — and name the two things it can't catch: the IAllowAnonymous precedence rule (no shipped route exercises it; covered only by the existing test-only-endpoint tests, ADR-0034's accepted gap) and any specific route staying anonymous (expectation and document derive from the same live metadata, so they move together). Full CI sequence, including dotnet format --verify-no-changes, is clean.

Rewrote the new test's doc comment (912ff44) to state what it actually proves — membership parity, mutation-verified both ways — and name the two things it can't catch: the `IAllowAnonymous` precedence rule (no shipped route exercises it; covered only by the existing test-only-endpoint tests, ADR-0034's accepted gap) and any specific route staying anonymous (expectation and document derive from the same live metadata, so they move together). Full CI sequence, including `dotnet format --verify-no-changes`, is clean.
rob merged commit 17e8ab0ef4 into main 2026-08-06 04:29:27 +00:00
rob deleted branch feat/openapi-bearer-assertion 2026-08-06 04:29:27 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!80
No description provided.