Files
Teamup/tests/TeamUp.IntegrationTests/IdentityFlowTests.cs
T
soroush.asadi e1911f58b1 M1: OrgBoard — organizations, teams, seats, the board & cartable
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>
2026-06-09 11:58:20 +03:30

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!;
}
}