M1: Identity & access — members, RBAC, JWT auth, invitations
Adds the access foundation everything else enforces against. SharedKernel (shared access contracts, no Identity dependency for consumers): - ScopeRef/ScopeType, RoleType, Capability, AccessPolicy (role x capability matrix), ICurrentUser, IPermissionService (scope-chain evaluation). Identity module: - Member, Membership, Invitation entities; internal IdentityDbContext (schema "identity") + InitialIdentity migration; design-time factory. - JWT auth (HS256) issuing membership claims; PasswordHasher<Member>; CurrentUser (claims -> ICurrentUser) and PermissionService implementations. - Public IMemberDirectory contract for other modules to resolve member display info. - Endpoints: POST /bootstrap (first owner), /auth/login, GET /me, POST /invitations, POST /invitations/accept. Owner-only actions enforced via IPermissionService. - Web host wires UseAuthentication/UseAuthorization and string-enum JSON. Verified: build green; ArchitectureTests 8/8 (Identity references only SharedKernel); IntegrationTests 11/11 incl. a new end-to-end flow — bootstrap -> login -> /me -> invite -> accept -> login as invitee, and a Member is 403'd from inviting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace TeamUp.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<MembershipDto> 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<BootstrapResponse>();
|
||||
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<InviteResponse>();
|
||||
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<AuthResponse>();
|
||||
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<MeResponse> GetMe(TeamUpWebFactory factory, string token)
|
||||
{
|
||||
using var client = Authed(factory, token);
|
||||
var me = await client.GetFromJsonAsync<MeResponse>("/api/identity/me");
|
||||
Assert.NotNull(me);
|
||||
return me!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user