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.
"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",
// Gated on Share inside the handler as the two writes above are, and additionally on holding the
// current key — which no policy could express, since it is a row in vault_key_grant.
"POST /api/v1/vaults/{vaultId:guid}/rekey name=RekeyVault 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}";
}
}