using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Xunit;
namespace TeamUp.IntegrationTests;
///
/// M1 Identity/access acceptance at the API level: bootstrap the first owner, log in, read /me,
/// invite a member, accept the invite, and confirm a Member cannot perform an owner-only action.
///
public sealed class IdentityFlowTests(PostgresFixture postgres) : IClassFixture
{
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
private sealed record AuthResponse(string Token, Guid MemberId);
private sealed record InviteResponse(Guid InvitationId, string Token);
private sealed record MembershipDto(string ScopeType, Guid ScopeId, string Role);
private sealed record MeResponse(Guid MemberId, string Email, string DisplayName, List Memberships);
[Fact]
public async Task Bootstrap_login_invite_accept_and_rbac_enforcement()
{
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
using var client = factory.CreateClient();
// First owner is created by bootstrap.
var bootstrap = await client.PostAsJsonAsync("/api/identity/bootstrap", new
{
organizationName = "AliaSaaS",
ownerEmail = "owner@alia.test",
ownerDisplayName = "Owner",
ownerPassword = "Passw0rd!",
});
Assert.Equal(HttpStatusCode.OK, bootstrap.StatusCode);
var owner = await bootstrap.Content.ReadFromJsonAsync();
Assert.NotNull(owner);
// Bootstrapping again is rejected.
var second = await client.PostAsJsonAsync("/api/identity/bootstrap", new
{
organizationName = "x",
ownerEmail = "x@x.test",
ownerDisplayName = "X",
ownerPassword = "Passw0rd!",
});
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
// /me requires auth.
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/identity/me")).StatusCode);
// Owner reads /me and holds an Owner membership.
var me = await GetMe(factory, owner!.Token);
Assert.Equal("owner@alia.test", me.Email);
Assert.Contains(me.Memberships, m => m.Role == "Owner" && m.ScopeId == owner.OrganizationId);
// Owner invites a Member at the org scope.
var invite = await Authed(factory, owner.Token).PostAsJsonAsync("/api/identity/invitations", new
{
email = "dev@alia.test",
scopeType = "Organization",
scopeId = owner.OrganizationId,
role = "Member",
organizationId = owner.OrganizationId,
});
Assert.Equal(HttpStatusCode.OK, invite.StatusCode);
var inviteResponse = await invite.Content.ReadFromJsonAsync();
Assert.NotNull(inviteResponse);
// The invitee accepts and gets a token.
var accept = await client.PostAsJsonAsync("/api/identity/invitations/accept", new
{
token = inviteResponse!.Token,
displayName = "Dev",
password = "Passw0rd!",
});
Assert.Equal(HttpStatusCode.OK, accept.StatusCode);
var member = await accept.Content.ReadFromJsonAsync();
Assert.NotNull(member);
// The new member can log in with their credentials.
var login = await client.PostAsJsonAsync("/api/identity/auth/login", new
{
email = "dev@alia.test",
password = "Passw0rd!",
});
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
// A Member cannot invite (owner-only capability) → 403.
var memberInvite = await Authed(factory, member!.Token).PostAsJsonAsync("/api/identity/invitations", new
{
email = "nope@alia.test",
scopeType = "Organization",
scopeId = owner.OrganizationId,
role = "Member",
organizationId = owner.OrganizationId,
});
Assert.Equal(HttpStatusCode.Forbidden, memberInvite.StatusCode);
// Bad credentials are rejected.
var badLogin = await client.PostAsJsonAsync("/api/identity/auth/login", new
{
email = "owner@alia.test",
password = "wrong",
});
Assert.Equal(HttpStatusCode.Unauthorized, badLogin.StatusCode);
}
private static HttpClient Authed(TeamUpWebFactory factory, string token)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
private static async Task GetMe(TeamUpWebFactory factory, string token)
{
using var client = Authed(factory, token);
var me = await client.GetFromJsonAsync("/api/identity/me");
Assert.NotNull(me);
return me!;
}
}