using System.Reflection;
using Elsa.Identity.Contracts;
using Elsa.Identity.Endpoints.Users.Create;
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
using FastEndpoints;
using Microsoft.AspNetCore.Http;
using NSubstitute;
namespace Elsa.Identity.UnitTests.Endpoints;
///
/// Pins the POST /identity/users response contract: no hashes or salts, no echo of a supplied password,
/// and a generated password returned exactly once.
///
public class CreateUserContractTests
{
private static readonly User StoredUser = new()
{
Id = "user-1",
Name = "alice",
Roles = ["admin"],
TenantId = "tenant-a",
HashedPassword = "hash-must-not-leak",
HashedPasswordSalt = "salt-must-not-leak"
};
[Fact]
public void ResponseExposesOnlyAccountFieldsAndTheGeneratedPassword()
{
var properties = typeof(Response).GetProperties(BindingFlags.Instance | BindingFlags.Public).Select(x => x.Name).OrderBy(x => x).ToArray();
Assert.Equal(["GeneratedPassword", "Id", "Name", "Roles", "TenantId"], properties);
Assert.DoesNotContain(properties, x => x.Contains("Hash", StringComparison.OrdinalIgnoreCase) || x.Contains("Salt", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void FromResultWithSuppliedPasswordDoesNotEchoIt()
{
var response = Response.FromResult(new CreateUserResult(StoredUser, "supplied-secret", IsPasswordGenerated: false));
Assert.Null(response.GeneratedPassword);
Assert.Equal("user-1", response.Id);
Assert.Equal("alice", response.Name);
Assert.Equal(["admin"], response.Roles);
Assert.Equal("tenant-a", response.TenantId);
}
[Fact]
public void FromResultWithGeneratedPasswordReturnsItOnce()
{
var response = Response.FromResult(new CreateUserResult(StoredUser, "generated-secret", IsPasswordGenerated: true));
Assert.Equal("generated-secret", response.GeneratedPassword);
}
[Theory]
[InlineData("supplied-secret", false, null)]
[InlineData(null, true, "generated-secret")]
public async Task EndpointNeverSerializesCredentialMaterial(string? suppliedPassword, bool generated, string? expectedGeneratedPassword)
{
var plainText = suppliedPassword ?? "generated-secret";
var userManager = Substitute.For();
userManager.CreateUserAsync("alice", suppliedPassword, Arg.Any?>(), Arg.Any())
.Returns(new CreateUserResult(StoredUser, plainText, generated));
var roleAuthorization = Substitute.For();
roleAuthorization.CanAssignRolesAsync(Arg.Any(), Arg.Any?>(), Arg.Any()).Returns(true);
var body = new MemoryStream();
var endpoint = Factory.Create(context => context.Response.Body = body, userManager, roleAuthorization);
await endpoint.HandleAsync(new Request { Name = "alice", Password = suppliedPassword, Roles = ["admin"] }, CancellationToken.None);
Assert.Equal(StatusCodes.Status200OK, endpoint.HttpContext.Response.StatusCode);
Assert.Equal(expectedGeneratedPassword, endpoint.Response.GeneratedPassword);
var json = System.Text.Encoding.UTF8.GetString(body.ToArray());
Assert.DoesNotContain("hash-must-not-leak", json);
Assert.DoesNotContain("salt-must-not-leak", json);
Assert.DoesNotContain("supplied-secret", json);
if (expectedGeneratedPassword is not null)
Assert.Contains(expectedGeneratedPassword, json);
}
}