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. /// [Collection(PostgresCollection.Name)] public sealed class IdentityFlowTests(PostgresFixture postgres) { 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!; } }