e1911f58b1
OrgBoard module (references SharedKernel only; RBAC via ICurrentUser/IPermissionService):
- Organization, Team, Seat (human/open/ai), WorkItem (board task: type, status, assignee,
parent) entities; internal OrgBoardDbContext (schema "orgboard") + InitialOrgBoard
migration; design-time factory. (WorkItem avoids the System.Threading.Tasks.Task clash.)
- Endpoints under /api/orgboard, every mutation permission-checked at the scope chain
[team, org]: POST /organizations, POST/GET /teams, POST /tasks, GET /board (columns
backlog->in progress->in review->done), PATCH /tasks/{id}/move, /assign, GET /cartable.
Test isolation: integration tests now use IClassFixture so each class gets its own
pgvector container (the bootstrap-once rule made a shared container collide).
Verified: build green; ArchitectureTests 8/8 (OrgBoard references only SharedKernel);
IntegrationTests 12/12 incl. a new board flow — owner sets up org+team, creates/moves/
assigns a task, sees it on the board and in the cartable; an invited Member can view the
board but is 403'd from creating a team.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
127 lines
4.9 KiB
C#
127 lines
4.9 KiB
C#
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>
|
|
public sealed class IdentityFlowTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
|
{
|
|
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!;
|
|
}
|
|
}
|