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", // Enrolled, because the answer exists to be wrapped to and a caller with no key of their own has // nothing to wrap and no signature to attribute it with. There is no search here — see // DirectoryService for why an exact-match-only directory is a decision rather than a shortcut. "GET /api/v1/directory name=LookupDirectory tags=Identity policies=Enrolled anon=False", // The other half of the same decision: the directory says what a key is, this is how a client // checks that claim against a chain the server cannot rewrite without every other client // noticing. Nothing in it is secret. "GET /api/v1/keylog name=ReadKeyLog tags=Identity policies=Enrolled anon=False", // Authenticated, not Enrolled: reading and joining teams needs no key, and a member added before // they have set a vault up must still be able to see the team they are now in. "GET /api/v1/teams name=ListTeams tags=Teams policies=Authenticated anon=False", "GET /api/v1/teams/{teamId:guid}/members name=ListTeamMembers tags=Teams policies=Authenticated anon=False", "POST /api/v1/teams/{teamId:guid}/members name=AddTeamMember tags=Teams policies=Authenticated anon=False", "PUT /api/v1/teams/{teamId:guid}/members/{userId:guid}/role name=ChangeTeamMemberRole tags=Teams policies=Authenticated anon=False", "DELETE /api/v1/teams/{teamId:guid}/members/{userId:guid} name=RemoveTeamMember tags=Teams policies=Authenticated anon=False", // Administering a team you are already in, and none of it moves key material — so Authenticated // for the same reason the membership routes above are. Two of the three are gated harder inside // the handler than this table can show: archiving and handing the team over check for the owner // rather than for an admin, because an admin the owner promoted must not be able to take the // team from them. See TeamAccess.IsOwner. "PUT /api/v1/teams/{teamId:guid} name=UpdateTeam tags=Teams policies=Authenticated anon=False", "DELETE /api/v1/teams/{teamId:guid} name=ArchiveTeam tags=Teams policies=Authenticated anon=False", "POST /api/v1/teams/{teamId:guid}/owner name=TransferTeamOwnership tags=Teams policies=Authenticated anon=False", // Authenticated, and pointedly not Enrolled. An invitation names an address that may have no // account at all and certainly holds no key; gating these on Enrolled would be demanding a key // of the one participant the feature exists for. Membership is not readability — somebody still // has to wrap the vault key afterwards — so no key is involved on either side. "GET /api/v1/teams/{teamId:guid}/invitations name=ListTeamInvitations tags=Teams policies=Authenticated anon=False", "POST /api/v1/teams/{teamId:guid}/invitations name=CreateTeamInvitation tags=Teams policies=Authenticated anon=False", "DELETE /api/v1/teams/{teamId:guid}/invitations/{invitationId:guid} name=RevokeTeamInvitation tags=Teams policies=Authenticated anon=False", // Enrolled, because both end in a vault key being wrapped: creating a team means creating a vault // in it, and neither is reachable without a key of one's own. "POST /api/v1/teams name=CreateTeam tags=Teams policies=Enrolled anon=False", "POST /api/v1/teams/{teamId:guid}/vaults name=CreateTeamVault tags=Teams policies=Enrolled anon=False", // Enrolled. The listing is gated on Read rather than Share — every member can already see the // sharing graph — and the two writes are gated on Share inside the handler, which this table // cannot see. See VaultGrantEndpoints. // Authenticated, alone among the vault routes: renaming touches no key material, so refusing // somebody who has not published an identity key would be refusing them for an unrelated // reason. Gated on Admin inside the handler, which this table cannot see. "PUT /api/v1/vaults/{vaultId:guid} name=RenameVault tags=Vaults policies=Authenticated anon=False", "GET /api/v1/vaults/{vaultId:guid}/grants name=ListVaultGrants tags=Vaults policies=Enrolled anon=False", "POST /api/v1/vaults/{vaultId:guid}/grants name=IssueVaultGrant tags=Vaults policies=Enrolled anon=False", "DELETE /api/v1/vaults/{vaultId:guid}/grants/{userId:guid} name=RevokeVaultGrant tags=Vaults 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}"; } }