using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Shouldly; using Xunit; namespace DodoSSH.Api.Tests; /// /// Every route the server exposes, and the authorization decision behind each one. /// /// /// /// The inventory test ADR 0002 asked for. Endpoints are registered from an explicit type list in /// Setup/EndpointRegistration.cs rather than found by scanning, which trades one failure mode for /// another: nothing can appear by accident, but a type left off the list is a route that quietly does /// not exist, with no compile error and — without this test — no failure either. Asserting the whole set /// rather than a subset is the point. A new endpoint cannot ship until somebody writes down, here, who /// is allowed to call it. /// /// /// It is also the only coverage /api/v1/meta and /.well-known/dodossh-configuration have /// in this assembly — DodoSSH.SystemTests does fetch both anonymously, but that suite /// needs a Docker daemon and several containers, so it is not what a developer runs before pushing. /// Both are anonymous only because they say so, and a client has nothing to authenticate with at the /// point it reads them, so a 401 on either is unrecoverable rather than merely wrong. /// /// /// What this does not cover: reads only , /// so a role, claim, scope or authentication-scheme requirement could be added or removed without /// failing here. Nothing uses those today; the day something does, this table needs a column. /// /// [Collection(ApiCollection.Name)] public sealed class EndpointInventoryTests(ApiFixture fixture) { private static readonly string[] Expected = [ // Anonymous by necessity: discovery has to work before a token exists. "GET /api/v1/meta name=GetMeta tags= policies= anon=True", "GET /.well-known/dodossh-configuration name=GetDodoSshConfiguration tags= policies= anon=True", // Authenticated, not Enrolled: these three are how a caller discovers it must enroll, does so, // and — for revocation — withdraws a lost machine even if its enrollment state is in doubt. "GET /api/v1/me name=GetMe tags=Identity policies=Authenticated anon=False", "POST /api/v1/me/enrollment name=Enroll tags=Identity policies=Authenticated anon=False", "DELETE /api/v1/me/devices/{deviceId:guid} name=RevokeDevice tags=Identity policies=Authenticated anon=False", // The one endpoint in the /me area that needs a key bundle to already exist. "POST /api/v1/me/devices name=RegisterDevice tags=Identity policies=Authenticated,Enrolled anon=False", // Enrolled: a caller with no identity key can neither write ciphertext anyone can read nor read // what is there. "POST /api/v1/vaults/{vaultId:guid}/sync/pull name=SyncPull tags=Sync policies=Enrolled anon=False", "POST /api/v1/vaults/{vaultId:guid}/sync/push name=SyncPush tags=Sync policies=Enrolled anon=False", // Anonymous on purpose, and load-bearing: DodoSSH.SystemTests waits on /healthz/ready before any // token exists, and an orchestrator probe that needs credentials reports the wrong thing. // MapHealthChecks constrains no verb, hence ANY. "ANY /healthz/live name= tags= policies= anon=True", "ANY /healthz/ready name= tags= policies= anon=True", "ANY /healthz/startup name= tags= policies= anon=True", // Not ours. FastEndpoints maps this one itself, in every environment, with no way to opt out; it // answers with the server's whole endpoint-name-to-route table. It stays registered and is // short-circuited to 404 instead — see RouteTableIsNotReachable below. Listed so that a version // bump which adds a second hidden route, or renames this one out from under the block, fails // here rather than in production. "GET _test_url_cache_ name= tags= policies= anon=False", ]; [Fact] public void TheServerExposesExactlyTheEndpointsWeMeantTo() { // Forces the pipeline to be built. FastEndpoints registers its routes when the application is // built, not when its services are, so resolving the data sources from an unstarted host finds // the health checks and nothing else. using var client = fixture.CreateClient(); var actual = fixture.Services.GetServices() .SelectMany(source => source.Endpoints) .OfType() .Select(Describe) .Order(StringComparer.Ordinal) .ToArray(); actual.ShouldBe([.. Expected.Order(StringComparer.Ordinal)]); } /// /// /// Distinct from the inventory above, which only proves the route is registered. This proves it does /// not answer. /// /// /// Every spelling routing accepts is asserted, not just the canonical one. Routing matches literal /// segments case-insensitively and tolerates a trailing slash, so a block that compares the request /// path with StringComparison.Ordinal passes a canonical-spelling test while leaving the /// listing fully readable at /_TEST_URL_CACHE_. That is not hypothetical — it is what the /// first version of this guard did, and a single-spelling test is what let it look correct. /// /// [Theory] [InlineData("/_test_url_cache_")] [InlineData("/_TEST_URL_CACHE_")] [InlineData("/_Test_Url_Cache_")] [InlineData("/_test_url_cache_/")] public async Task RouteTableIsNotReachable(string path) { // Authenticated on purpose: the deny-by-default policy already stops an anonymous caller, so a // 404 for one would prove nothing about whether the listing is exposed. var client = fixture.CreateClientFor(subject: $"route-table-{Guid.CreateVersion7()}"); var response = await client.GetAsync( new Uri(path, UriKind.Relative), TestContext.Current.CancellationToken); response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound); // A 404 with the listing in the body would satisfy the status assertion on its own. var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); body.ShouldNotContain("Endpoint", Case.Insensitive); } private static string Describe(RouteEndpoint endpoint) { var methods = endpoint.Metadata.GetMetadata()?.HttpMethods; var verbs = methods is { Count: > 0 } ? string.Join(",", methods) : "ANY"; var name = endpoint.Metadata.GetMetadata()?.EndpointName ?? string.Empty; var tags = string.Join(",", endpoint.Metadata.GetMetadata()?.Tags ?? []); // FastEndpoints adds a synthetic "epPolicy:" beside the named policies, and // including it would couple this table to endpoint class names. Dropping it is not free: that // policy is also where FastEndpoints folds Roles(), Claims() and Permissions(), so those become // invisible here. Nothing calls them — the two named policies carry the whole authorization // decision — and the class remarks say so, rather than this filter pretending it discards nothing. var policies = string.Join( ",", endpoint.Metadata.OfType() .Select(data => data.Policy) .Where(policy => !string.IsNullOrEmpty(policy)) .Where(policy => !policy!.StartsWith("epPolicy:", StringComparison.Ordinal))); var anonymous = endpoint.Metadata.GetMetadata() is not null; return $"{verbs} {endpoint.RoutePattern.RawText} name={name} tags={tags} policies={policies} anon={anonymous}"; } }