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;
}
}