using System.Net; using System.Text; using DodoSSH.Contracts; using DodoSSH.Domain; using DodoSSH.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Shouldly; using Xunit; namespace DodoSSH.Api.Tests; /// /// What the server answers when a request body cannot be read at all. /// /// /// /// This is the one error the endpoint framework produces rather than a handler, so it is the one place /// the problem-document contract could drift without any other test noticing. The strict /// unmapped-member rule in particular has never had a test: it is configured in /// , applied by AddDodoJson, and until now nothing asserted the /// server actually enforced it. /// /// /// Enrollment is the subject only because it is a POST with a body that an authenticated caller can /// reach without being enrolled. Nothing here is about enrollment. /// /// [Collection(ApiCollection.Name)] public sealed class RequestBindingTests(ApiFixture fixture) { private const string EnrollUrl = "/api/v1/me/enrollment"; [Fact] public async Task AnUnknownJsonMemberIsRejected() { // A misspelled or renamed client property must be a 400, not a silently missing value that // shows up later as data loss. var problem = await PostRawAsync("""{"statement":null,"thisMemberDoesNotExist":1}"""); problem.Code.ShouldBe(ProblemCodes.MalformedRequest); } [Fact] public async Task MalformedJsonIsRejected() { var problem = await PostRawAsync("""{"statement":""" + " "); problem.Code.ShouldBe(ProblemCodes.MalformedRequest); } [Fact] public async Task ANumberSentAsAStringIsRejected() { // JsonNumberHandling.Strict. The web defaults would read "1" as 1, which is how two // implementations end up disagreeing about what a number is. // // keyGeneration is reached through statement on purpose: it is a member of KeyStatement, not of // EnrollmentRequest, so posting it at the top level is rejected as an unmapped member and never // reaches a number-parsing decision at all. This test asserted nothing until that was spotted. var problem = await PostRawAsync("""{"statement":{"keyGeneration":"1"}}"""); problem.Code.ShouldBe(ProblemCodes.MalformedRequest); } [Fact] public async Task TheQueryStringCannotSupplyRequestFields() { // FastEndpoints' default binder writes query-string values over the deserialised body; minimal // APIs read the body alone. Left on, that would let ?identityProviderToken=... put an ID token // in a URL, and from there into every proxy log and browser history on the path. // // Asserted on the cursor because it is the one field where the two behaviours give visibly // different answers: a body cursor of null means "start from the beginning" and succeeds, while // the garbage in the query string fails its integrity tag. A test on enrollment could not tell // the difference — every malformed enrollment is rejected either way. var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); var response = await client.PostContractAsync( $"/api/v1/vaults/{vaultId}/sync/pull?cursor=bm90LWEtcmVhbC1jdXJzb3I", new SyncPullRequest(Cursor: null, Limit: null, EntityTypes: null)); response.StatusCode.ShouldBe(HttpStatusCode.OK); } [Fact] public async Task TheRejectionIsARfc9457ProblemDocument() { // The framework's own binding-failure body is neither a problem document nor free of .NET type // names, despite being served as application/problem+json. Asserted in full here because this // response is assembled by hand rather than by TypedResults.Problem, so nothing else keeps the // two shapes in step. var client = fixture.CreateClientFor(subject: $"binding-{Guid.CreateVersion7()}"); using var content = new StringContent( """{"nope":1}""", Encoding.UTF8, "application/json"); var response = await client.PostAsync( new Uri(EnrollUrl, UriKind.Relative), content, TestContext.Current.CancellationToken); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); response.Content.Headers.ContentType?.MediaType.ShouldBe("application/problem+json"); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); body.ShouldNotContain("DodoSSH.Contracts", Case.Sensitive); var problem = await response.Content.ReadProblemAsync(); problem.ShouldNotBeNull(); problem.Type.ShouldBe(ProblemCodes.TypeBaseUri + ProblemCodes.MalformedRequest); problem.Detail.ShouldNotBeNullOrWhiteSpace(); } /// /// Seeded directly rather than driven through the enrollment endpoint, for the reason /// gives: a failure here should mean the binder is wrong, not that enrollment is. /// private async Task<(string Subject, Guid VaultId)> SeedUserWithVaultAsync() { var subject = $"binding-{Guid.CreateVersion7():N}"; var now = TimeProvider.System.GetUtcNow(); await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); var user = new UserAccount { Id = Guid.CreateVersion7(), Issuer = fixture.IdentityProvider.Authority, Subject = subject, Status = UserStatus.Active, CreatedAtUtc = now, UpdatedAtUtc = now, }; var vault = new Vault { Id = Guid.CreateVersion7(), Name = "Personal", OwnerKind = VaultOwnerKind.Personal, OwnerUserId = user.Id, KeyGeneration = 1, CreatedAtUtc = now, UpdatedAtUtc = now, }; database.Users.Add(user); database.UserKeys.Add(Seed.CurrentKey(user.Id, now)); database.Vaults.Add(vault); await database.SaveChangesAsync(TestContext.Current.CancellationToken); return (subject, vault.Id); } private async Task PostRawAsync(string json, string query = "") { var client = fixture.CreateClientFor(subject: $"binding-{Guid.CreateVersion7()}"); using var content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await client.PostAsync( new Uri(EnrollUrl + query, UriKind.Relative), content, TestContext.Current.CancellationToken); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); var problem = await response.Content.ReadProblemAsync(); problem.ShouldNotBeNull(); return problem; } }