|
|
|
@@ -5,7 +5,7 @@ using DodoSSH.Contracts;
|
|
|
|
|
namespace DodoSSH.Api.Tests;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Teams, membership and the vault key grants that make a team vault readable.
|
|
|
|
|
/// Teams, membership, invitations and the vault key grants that make a team vault readable.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
@@ -21,13 +21,23 @@ namespace DodoSSH.Api.Tests;
|
|
|
|
|
/// for a key its recipient no longer holds. Each of those looks exactly like working software from the
|
|
|
|
|
/// outside.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Two boundaries in here are load-bearing beyond their own endpoint, and both are of that invisible
|
|
|
|
|
/// kind. An admin who can archive a team or hand it away is an admin who can take it from the person
|
|
|
|
|
/// who promoted them, and nothing about the response would say so. An invitation claimed on an address
|
|
|
|
|
/// the identity provider never vouched for is a way into somebody else's team, and every other part of
|
|
|
|
|
/// that request succeeds. Neither has a symptom; each has a test.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Collection(ApiCollection.Name)]
|
|
|
|
|
public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
{
|
|
|
|
|
private const string TeamsUrl = "/api/v1/teams";
|
|
|
|
|
private const string MeUrl = "/api/v1/me";
|
|
|
|
|
private const string EnrollUrl = "/api/v1/me/enrollment";
|
|
|
|
|
|
|
|
|
|
// ---- Creating and listing ----
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task CreatingATeam_MakesTheCallerItsOwner()
|
|
|
|
|
{
|
|
|
|
@@ -79,11 +89,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
var response = await client.PostContractAsync(
|
|
|
|
|
TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "Second", slug, null));
|
|
|
|
|
|
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
|
|
|
|
|
|
|
|
|
|
var problem = await response.Content.ReadProblemAsync();
|
|
|
|
|
|
|
|
|
|
problem.Code.ShouldBe(ProblemCodes.TeamSlugTaken);
|
|
|
|
|
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.TeamSlugTaken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
@@ -98,18 +104,325 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Private");
|
|
|
|
|
|
|
|
|
|
var real = await stranger.GetAsync(
|
|
|
|
|
new Uri($"{TeamsUrl}/{team.TeamId}/members", UriKind.Relative),
|
|
|
|
|
TestContext.Current.CancellationToken);
|
|
|
|
|
|
|
|
|
|
var invented = await stranger.GetAsync(
|
|
|
|
|
new Uri($"{TeamsUrl}/{Guid.CreateVersion7()}/members", UriKind.Relative),
|
|
|
|
|
TestContext.Current.CancellationToken);
|
|
|
|
|
var real = await GetAsync(stranger, MembersUrl(team.TeamId));
|
|
|
|
|
var invented = await GetAsync(stranger, MembersUrl(Guid.CreateVersion7()));
|
|
|
|
|
|
|
|
|
|
real.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
|
|
|
|
invented.StatusCode.ShouldBe(real.StatusCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Renaming ----
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The rename is read back from the list rather than from the response body, because the list is
|
|
|
|
|
/// what a member's screen is built from and it is assembled by different code. The slug is asserted
|
|
|
|
|
/// unchanged because it is unique only among live teams: a rename that moved it could take a slug an
|
|
|
|
|
/// archived team still holds, and that archived team could then never be brought back.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task RenamingATeam_ChangesTheListedNameAndLeavesTheSlugAlone()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("rename-owner");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Before");
|
|
|
|
|
|
|
|
|
|
var response = await owner.PutContractAsync(
|
|
|
|
|
TeamUrl(team.TeamId), new UpdateTeamRequest("After", "A description it did not have."));
|
|
|
|
|
|
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
|
|
|
|
|
|
|
|
|
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
|
|
|
|
|
var renamed = listed.Where(row => row.TeamId == team.TeamId).ShouldHaveSingleItem();
|
|
|
|
|
|
|
|
|
|
renamed.Name.ShouldBe("After");
|
|
|
|
|
renamed.Description.ShouldBe("A description it did not have.");
|
|
|
|
|
renamed.Slug.ShouldBe(team.Slug);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Null clears the description rather than leaving it alone. A PUT that treated an absent value as
|
|
|
|
|
/// "no change" would make a cleared description impossible to express at all, since there is no
|
|
|
|
|
/// other verb for it.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task RenamingWithoutADescription_ClearsTheOneThatWasThere()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("rename-clear-owner");
|
|
|
|
|
|
|
|
|
|
var team = await PostAsync<CreateTeamRequest, TeamSummary>(
|
|
|
|
|
owner,
|
|
|
|
|
TeamsUrl,
|
|
|
|
|
new CreateTeamRequest(
|
|
|
|
|
Guid.CreateVersion7(), "Described", $"team-{Guid.CreateVersion7():N}", "Something."));
|
|
|
|
|
|
|
|
|
|
team.Description.ShouldBe("Something.");
|
|
|
|
|
|
|
|
|
|
var response = await owner.PutContractAsync(
|
|
|
|
|
TeamUrl(team.TeamId), new UpdateTeamRequest("Described", null));
|
|
|
|
|
|
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
|
|
|
|
|
|
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
|
|
|
|
|
|
|
|
|
|
listed.Where(row => row.TeamId == team.TeamId)
|
|
|
|
|
.ShouldHaveSingleItem()
|
|
|
|
|
.Description.ShouldBeNull();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// A member is refused with 403 rather than 404: the team is visible to them, so naming the reason
|
|
|
|
|
/// leaks nothing and "you are not an admin" is a more useful answer than "no such team".
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task APlainMember_CanRenameNothingAndCanNeitherArchiveNorHandOverTheTeam()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("member-limits-owner", "mlowner@example.com");
|
|
|
|
|
var member = await EnrolledClientAsync("member-limits-member", "mlmember@example.com");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Limits");
|
|
|
|
|
var entry = await LookupAsync(owner, "mlmember@example.com");
|
|
|
|
|
var memberMe = await ReadAsync<MeResponse>(member, MeUrl);
|
|
|
|
|
|
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
|
|
|
|
|
|
|
|
|
var renamed = await member.PutContractAsync(
|
|
|
|
|
TeamUrl(team.TeamId), new UpdateTeamRequest("Theirs now", null));
|
|
|
|
|
|
|
|
|
|
await ShouldBeProblemAsync(renamed, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
|
|
|
|
|
|
|
|
|
|
var archived = await DeleteAsync(member, TeamUrl(team.TeamId));
|
|
|
|
|
|
|
|
|
|
await ShouldBeProblemAsync(archived, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
|
|
|
|
|
|
|
|
|
|
var transferred = await member.PostContractAsync(
|
|
|
|
|
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(memberMe.UserId));
|
|
|
|
|
|
|
|
|
|
await ShouldBeProblemAsync(transferred, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>The <c>TeamAccess.IsOwner</c> boundary, and the most important authorization test in this
|
|
|
|
|
/// feature.</b> An admin may manage members and vaults, and that is deliberately not the same
|
|
|
|
|
/// permission as deciding whether the team continues to exist or who controls it. If either of
|
|
|
|
|
/// these checks were widened to <c>CanAdminister</c> — which is what every neighbouring endpoint
|
|
|
|
|
/// uses, so it is the easy mistake — anybody the owner promoted could archive the team out from
|
|
|
|
|
/// under them or take it outright, and nothing in the response of any other test would change.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// The rename is asserted in the same test on purpose: it is what shows the admin genuinely holds
|
|
|
|
|
/// administrative rights here, so the two refusals are the boundary rather than a broken role.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task AnAdminWhoIsNotTheOwner_MayRenameButMayNotArchiveOrHandOverTheTeam()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("admin-boundary-owner", "abowner@example.com");
|
|
|
|
|
var admin = await EnrolledClientAsync("admin-boundary-admin", "abadmin@example.com");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Boundary");
|
|
|
|
|
var entry = await LookupAsync(owner, "abadmin@example.com");
|
|
|
|
|
var adminMe = await ReadAsync<MeResponse>(admin, MeUrl);
|
|
|
|
|
|
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Admin);
|
|
|
|
|
|
|
|
|
|
// They really are an admin: this one succeeds.
|
|
|
|
|
var renamed = await admin.PutContractAsync(
|
|
|
|
|
TeamUrl(team.TeamId), new UpdateTeamRequest("Renamed by an admin", null));
|
|
|
|
|
|
|
|
|
|
renamed.StatusCode.ShouldBe(HttpStatusCode.OK);
|
|
|
|
|
|
|
|
|
|
var archived = await DeleteAsync(admin, TeamUrl(team.TeamId));
|
|
|
|
|
|
|
|
|
|
await ShouldBeProblemAsync(archived, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
|
|
|
|
|
|
|
|
|
|
var transferred = await admin.PostContractAsync(
|
|
|
|
|
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(adminMe.UserId));
|
|
|
|
|
|
|
|
|
|
await ShouldBeProblemAsync(transferred, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
|
|
|
|
|
|
|
|
|
|
// And nothing moved: the team is still there and still owned by the person who made it.
|
|
|
|
|
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
|
|
|
|
|
owner, MembersUrl(team.TeamId));
|
|
|
|
|
|
|
|
|
|
var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);
|
|
|
|
|
|
|
|
|
|
members.Where(row => row.UserId == ownerMe.UserId)
|
|
|
|
|
.ShouldHaveSingleItem()
|
|
|
|
|
.Role.ShouldBe(TeamMemberRole.Owner);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Archiving ----
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Archiving hides a team from every member's list at once, and a team vault resolves through
|
|
|
|
|
/// membership — so archiving one that still owned vaults would take those vaults away from
|
|
|
|
|
/// everybody holding a key, including the caller, with no way back because nothing in this product
|
|
|
|
|
/// deletes a vault. The refusal is the end of that road rather than a step on it, which is why the
|
|
|
|
|
/// team is asserted still listed afterwards.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ArchivingATeamThatOwnsAVault_IsRefusedAndLeavesTheTeamListed()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("archive-vault-owner");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Occupied");
|
|
|
|
|
await CreateVaultAsync(owner, team.TeamId);
|
|
|
|
|
|
|
|
|
|
var response = await DeleteAsync(owner, TeamUrl(team.TeamId));
|
|
|
|
|
|
|
|
|
|
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.TeamNotEmpty);
|
|
|
|
|
|
|
|
|
|
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
|
|
|
|
|
|
|
|
|
|
listed.ShouldContain(row => row.TeamId == team.TeamId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Every member's list, not just the caller's. Memberships are archived with the team in the same
|
|
|
|
|
/// transaction, and a live membership pointing at an archived team would leave the other member
|
|
|
|
|
/// still seeing it — which is the shape a half-applied archive takes.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ArchivingAnEmptyTeam_RemovesItFromEveryMembersList()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("archive-owner", "aowner@example.com");
|
|
|
|
|
var member = await EnrolledClientAsync("archive-member", "amember@example.com");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Wound up");
|
|
|
|
|
var entry = await LookupAsync(owner, "amember@example.com");
|
|
|
|
|
|
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
|
|
|
|
|
|
|
|
|
(await ReadAsync<IReadOnlyList<TeamSummary>>(member, TeamsUrl))
|
|
|
|
|
.ShouldContain(row => row.TeamId == team.TeamId);
|
|
|
|
|
|
|
|
|
|
var response = await DeleteAsync(owner, TeamUrl(team.TeamId));
|
|
|
|
|
|
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
|
|
|
|
|
|
|
|
|
(await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl))
|
|
|
|
|
.ShouldNotContain(row => row.TeamId == team.TeamId);
|
|
|
|
|
|
|
|
|
|
(await ReadAsync<IReadOnlyList<TeamSummary>>(member, TeamsUrl))
|
|
|
|
|
.ShouldNotContain(row => row.TeamId == team.TeamId);
|
|
|
|
|
|
|
|
|
|
// And it is gone the way a team nobody is in is gone, rather than merely unlisted.
|
|
|
|
|
(await GetAsync(owner, MembersUrl(team.TeamId))).StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Ownership ----
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <b>Both roles, because either alone would pass while the team was broken.</b> Asserting only
|
|
|
|
|
/// that the recipient is now owner would pass with the team owned twice; asserting only that the
|
|
|
|
|
/// outgoing owner is an admin would pass with it owned by nobody. Ownership is sole and the two
|
|
|
|
|
/// writes are one transaction precisely so that neither of those states can exist, so the count of
|
|
|
|
|
/// owners is asserted too.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task TransferringOwnership_MakesTheTargetOwnerAndTheOutgoingOwnerAnAdmin()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("transfer-owner", "towner@example.com");
|
|
|
|
|
var successor = await EnrolledClientAsync("transfer-successor", "tsuccessor@example.com");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Handover");
|
|
|
|
|
var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);
|
|
|
|
|
var entry = await LookupAsync(owner, "tsuccessor@example.com");
|
|
|
|
|
|
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
|
|
|
|
|
|
|
|
|
var response = await owner.PostContractAsync(
|
|
|
|
|
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));
|
|
|
|
|
|
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
|
|
|
|
|
|
|
|
|
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
|
|
|
|
|
owner, MembersUrl(team.TeamId));
|
|
|
|
|
|
|
|
|
|
members.Where(row => row.UserId == entry.UserId)
|
|
|
|
|
.ShouldHaveSingleItem()
|
|
|
|
|
.Role.ShouldBe(TeamMemberRole.Owner);
|
|
|
|
|
|
|
|
|
|
members.Where(row => row.UserId == ownerMe.UserId)
|
|
|
|
|
.ShouldHaveSingleItem()
|
|
|
|
|
.Role.ShouldBe(TeamMemberRole.Admin, "the outgoing owner is demoted, not removed");
|
|
|
|
|
|
|
|
|
|
members.Count(row => row.Role == TeamMemberRole.Owner).ShouldBe(1);
|
|
|
|
|
|
|
|
|
|
// And the recipient is told so by the endpoint their own screen reads.
|
|
|
|
|
(await ReadAsync<IReadOnlyList<TeamSummary>>(successor, TeamsUrl))
|
|
|
|
|
.Where(row => row.TeamId == team.TeamId)
|
|
|
|
|
.ShouldHaveSingleItem()
|
|
|
|
|
.Role.ShouldBe(TeamMemberRole.Owner);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The thing that was impossible before this endpoint existed. An owner could not be removed and
|
|
|
|
|
/// could not be demoted, so somebody who left the company owning a team left it owned by them for
|
|
|
|
|
/// ever — a state only an operator with database access could fix.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task AfterATransfer_TheFormerOwnerCanFinallyBeRemoved()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("departing-owner", "downer@example.com");
|
|
|
|
|
var successor = await EnrolledClientAsync("departing-successor", "dsuccessor@example.com");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Departure");
|
|
|
|
|
var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);
|
|
|
|
|
var entry = await LookupAsync(owner, "dsuccessor@example.com");
|
|
|
|
|
|
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
|
|
|
|
|
|
|
|
|
var transferred = await owner.PostContractAsync(
|
|
|
|
|
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));
|
|
|
|
|
|
|
|
|
|
transferred.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
|
|
|
|
|
|
|
|
|
var removed = await DeleteAsync(successor, MemberUrl(team.TeamId, ownerMe.UserId));
|
|
|
|
|
|
|
|
|
|
removed.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
|
|
|
|
|
|
|
|
|
(await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl))
|
|
|
|
|
.ShouldNotContain(row => row.TeamId == team.TeamId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Adding somebody and handing them the team in one step would let an id supplied once take it, so
|
|
|
|
|
/// the recipient has to be an active member already. This is the same refusal that stops a stranger
|
|
|
|
|
/// being made owner by pasting their id.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task TransferringToSomebodyWhoIsNotAMember_IsRefused()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("transfer-closed-owner", "tcowner@example.com");
|
|
|
|
|
await EnrolledClientAsync("transfer-outsider", "toutsider@example.com");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Closed handover");
|
|
|
|
|
var entry = await LookupAsync(owner, "toutsider@example.com");
|
|
|
|
|
|
|
|
|
|
var response = await owner.PostContractAsync(
|
|
|
|
|
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));
|
|
|
|
|
|
|
|
|
|
await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeam);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task TransferringToYourself_IsRefused()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("transfer-self-owner");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Already mine");
|
|
|
|
|
var me = await ReadAsync<MeResponse>(owner, MeUrl);
|
|
|
|
|
|
|
|
|
|
var response = await owner.PostContractAsync(
|
|
|
|
|
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(me.UserId));
|
|
|
|
|
|
|
|
|
|
await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeam);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Membership ----
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The membership half of M3 in one test: a team vault appears in the other member's <c>/me</c> the
|
|
|
|
|
/// moment they are added, and it appears <em>without</em> a wrapped key. That null is the whole
|
|
|
|
@@ -128,7 +441,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
|
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
|
|
|
|
|
|
|
|
|
var me = await ReadAsync<MeResponse>(member, "/api/v1/me");
|
|
|
|
|
var me = await ReadAsync<MeResponse>(member, MeUrl);
|
|
|
|
|
var vault = me.Vaults.SingleOrDefault(summary => summary.VaultId == vaultId);
|
|
|
|
|
|
|
|
|
|
vault.ShouldNotBeNull("membership is what makes a team vault visible");
|
|
|
|
@@ -163,11 +476,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
var push = await viewer.PostContractAsync(
|
|
|
|
|
$"/api/v1/vaults/{vaultId}/sync/push", new SyncPushRequest([]));
|
|
|
|
|
|
|
|
|
|
push.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
|
|
|
|
|
|
|
|
|
var problem = await push.Content.ReadProblemAsync();
|
|
|
|
|
|
|
|
|
|
problem.Code.ShouldBe(ProblemCodes.Forbidden);
|
|
|
|
|
await ShouldBeProblemAsync(push, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
@@ -187,16 +496,14 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
|
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
|
|
|
|
|
|
|
|
|
var before = await ReadAsync<MeResponse>(member, "/api/v1/me");
|
|
|
|
|
var before = await ReadAsync<MeResponse>(member, MeUrl);
|
|
|
|
|
before.Vaults.ShouldContain(summary => summary.VaultId == vaultId);
|
|
|
|
|
|
|
|
|
|
var removed = await owner.DeleteAsync(
|
|
|
|
|
new Uri($"{TeamsUrl}/{team.TeamId}/members/{entry.UserId}", UriKind.Relative),
|
|
|
|
|
TestContext.Current.CancellationToken);
|
|
|
|
|
var removed = await DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId));
|
|
|
|
|
|
|
|
|
|
removed.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
|
|
|
|
|
|
|
|
|
var after = await ReadAsync<MeResponse>(member, "/api/v1/me");
|
|
|
|
|
var after = await ReadAsync<MeResponse>(member, MeUrl);
|
|
|
|
|
after.Vaults.ShouldNotContain(summary => summary.VaultId == vaultId);
|
|
|
|
|
|
|
|
|
|
var pull = await member.PostContractAsync(
|
|
|
|
@@ -223,9 +530,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
|
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
|
|
|
|
|
|
|
|
|
await owner.DeleteAsync(
|
|
|
|
|
new Uri($"{TeamsUrl}/{team.TeamId}/members/{entry.UserId}", UriKind.Relative),
|
|
|
|
|
TestContext.Current.CancellationToken);
|
|
|
|
|
await DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId));
|
|
|
|
|
|
|
|
|
|
var grants = await ReadAsync<VaultGrantsResponse>(owner, $"/api/v1/vaults/{vaultId}/grants");
|
|
|
|
|
|
|
|
|
@@ -233,9 +538,11 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The owner cannot be removed or demoted, because nothing can appoint a replacement yet. Refused
|
|
|
|
|
/// with a code the client can act on rather than a bare 400, since "you cannot do that" and "you did
|
|
|
|
|
/// that wrong" lead somewhere different.
|
|
|
|
|
/// The owner cannot be removed even now that ownership can be handed over, and this is the standing
|
|
|
|
|
/// guarantee rather than a leftover: transferring is what makes the team's next owner exist, so
|
|
|
|
|
/// removing the current one first would still leave it with nobody who can manage it. Refused with a
|
|
|
|
|
/// code the client can act on rather than a bare 400, since "you cannot do that" and "you did that
|
|
|
|
|
/// wrong" lead somewhere different.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task TheOwner_CannotBeRemoved()
|
|
|
|
@@ -243,19 +550,328 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
var owner = await EnrolledClientAsync("sole-owner", "sole@example.com");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Sole");
|
|
|
|
|
var me = await ReadAsync<MeResponse>(owner, "/api/v1/me");
|
|
|
|
|
var me = await ReadAsync<MeResponse>(owner, MeUrl);
|
|
|
|
|
|
|
|
|
|
var response = await owner.DeleteAsync(
|
|
|
|
|
new Uri($"{TeamsUrl}/{team.TeamId}/members/{me.UserId}", UriKind.Relative),
|
|
|
|
|
TestContext.Current.CancellationToken);
|
|
|
|
|
var response = await DeleteAsync(owner, MemberUrl(team.TeamId, me.UserId));
|
|
|
|
|
|
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
|
|
|
|
|
|
|
|
|
|
var problem = await response.Content.ReadProblemAsync();
|
|
|
|
|
|
|
|
|
|
problem.Code.ShouldBe(ProblemCodes.LastTeamOwner);
|
|
|
|
|
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.LastTeamOwner);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The other half of the same guarantee, and the one a transfer endpoint could plausibly have
|
|
|
|
|
/// loosened. Demoting the owner through the role endpoint cannot appoint a replacement in the same
|
|
|
|
|
/// breath, so it would leave the team ownerless — which is exactly the state
|
|
|
|
|
/// <c>TransferOwnershipAsync</c> does two writes in one transaction to avoid.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task TheOwner_CannotBeDemotedOnTheirOwn()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("demote-owner", "demote@example.com");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Undemotable");
|
|
|
|
|
var me = await ReadAsync<MeResponse>(owner, MeUrl);
|
|
|
|
|
|
|
|
|
|
var response = await owner.PutContractAsync(
|
|
|
|
|
MemberRoleUrl(team.TeamId, me.UserId),
|
|
|
|
|
new ChangeTeamMemberRoleRequest(TeamMemberRole.Admin));
|
|
|
|
|
|
|
|
|
|
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.LastTeamOwner);
|
|
|
|
|
|
|
|
|
|
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
|
|
|
|
|
owner, MembersUrl(team.TeamId));
|
|
|
|
|
|
|
|
|
|
members.Where(row => row.UserId == me.UserId)
|
|
|
|
|
.ShouldHaveSingleItem()
|
|
|
|
|
.Role.ShouldBe(TeamMemberRole.Owner);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// A real value, not a placeholder. <c>LastActiveAt</c> is the field the teams screen uses to say
|
|
|
|
|
/// whether a colleague has been here at all, and the failure it guards against is the one that made
|
|
|
|
|
/// the column impossible to offer before: a null for somebody who has plainly been making requests
|
|
|
|
|
/// reads as "never", which is a lie about a person.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task AMemberListing_ReportsWhenAnAccountWasLastActive()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("last-seen-owner");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Last seen");
|
|
|
|
|
var me = await ReadAsync<MeResponse>(owner, MeUrl);
|
|
|
|
|
|
|
|
|
|
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
|
|
|
|
|
owner, MembersUrl(team.TeamId));
|
|
|
|
|
|
|
|
|
|
var self = members.Where(row => row.UserId == me.UserId).ShouldHaveSingleItem();
|
|
|
|
|
|
|
|
|
|
self.LastActiveAt.ShouldNotBeNull(
|
|
|
|
|
"this account has made several authenticated requests already");
|
|
|
|
|
|
|
|
|
|
// Within the window the server writes on, which is the whole of the precision this carries.
|
|
|
|
|
self.LastActiveAt.Value.ShouldBeGreaterThan(TimeProvider.System.GetUtcNow().AddHours(-1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Invitations ----
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The expiry is asserted rather than merely present. An invitation that never lapsed would be a
|
|
|
|
|
/// standing offer against an address, and company addresses are handed to the next person to hold
|
|
|
|
|
/// the job — so the person who inherits the mailbox would inherit the team.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task InvitingAnAddress_ListsItAsPendingWithItsRoleAndAnExpiry()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("invite-owner");
|
|
|
|
|
var address = NewAddress();
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Invitations");
|
|
|
|
|
var created = await InviteAsync(owner, team.TeamId, address, TeamMemberRole.Admin);
|
|
|
|
|
|
|
|
|
|
var listed = await FindInvitationAsync(owner, team.TeamId, created.InvitationId);
|
|
|
|
|
|
|
|
|
|
listed.Email.ShouldBe(address);
|
|
|
|
|
listed.Role.ShouldBe(TeamMemberRole.Admin);
|
|
|
|
|
listed.State.ShouldBe(TeamInvitationState.Pending);
|
|
|
|
|
listed.AcceptedAt.ShouldBeNull();
|
|
|
|
|
(listed.ExpiresAt - listed.CreatedAt).ShouldBe(TimeSpan.FromDays(14));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The same body twice, as a client whose response was lost would send it — the shape team and
|
|
|
|
|
/// vault creation already have. Two invitations to one address would show the same person twice on
|
|
|
|
|
/// the teams screen and take two revocations to withdraw.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task RepeatingAnInvitation_ReturnsTheSameOneRatherThanASecond()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("invite-retry-owner");
|
|
|
|
|
var address = NewAddress();
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Retried invitations");
|
|
|
|
|
|
|
|
|
|
var request = new CreateTeamInvitationRequest(
|
|
|
|
|
Guid.CreateVersion7(), address, TeamMemberRole.Member);
|
|
|
|
|
|
|
|
|
|
var first = await PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
|
|
|
|
|
owner, InvitationsUrl(team.TeamId), request);
|
|
|
|
|
|
|
|
|
|
var second = await PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
|
|
|
|
|
owner, InvitationsUrl(team.TeamId), request);
|
|
|
|
|
|
|
|
|
|
second.InvitationId.ShouldBe(first.InvitationId);
|
|
|
|
|
|
|
|
|
|
var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
|
|
|
|
|
owner, InvitationsUrl(team.TeamId));
|
|
|
|
|
|
|
|
|
|
// Case-insensitively, because the column is citext and two addresses differing only in case
|
|
|
|
|
// are one address — a second row under a different casing would still be a second invitation.
|
|
|
|
|
listed.Count(row => string.Equals(row.Email, address, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
.ShouldBe(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ASecondInvitationToTheSameAddress_IsRefused()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("invite-duplicate-owner");
|
|
|
|
|
var address = NewAddress();
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Duplicate invitations");
|
|
|
|
|
|
|
|
|
|
await InviteAsync(owner, team.TeamId, address);
|
|
|
|
|
|
|
|
|
|
// A different id, so this is a second invitation rather than a retry of the first.
|
|
|
|
|
var response = await owner.PostContractAsync(
|
|
|
|
|
InvitationsUrl(team.TeamId),
|
|
|
|
|
new CreateTeamInvitationRequest(Guid.CreateVersion7(), address, TeamMemberRole.Admin));
|
|
|
|
|
|
|
|
|
|
await ShouldBeProblemAsync(
|
|
|
|
|
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Ownership is sole and is handed over deliberately. An invitation that conferred it would let an
|
|
|
|
|
/// address typed once take the team the moment somebody signed in with it — and the invitee is by
|
|
|
|
|
/// definition somebody nobody here has met.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task InvitingSomebodyAsOwner_IsRefused()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("invite-owner-role-owner");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Not for sale");
|
|
|
|
|
|
|
|
|
|
var response = await owner.PostContractAsync(
|
|
|
|
|
InvitationsUrl(team.TeamId),
|
|
|
|
|
new CreateTeamInvitationRequest(
|
|
|
|
|
Guid.CreateVersion7(), NewAddress(), TeamMemberRole.Owner));
|
|
|
|
|
|
|
|
|
|
await ShouldBeProblemAsync(
|
|
|
|
|
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task AMalformedAddress_IsRefused()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("invite-malformed-owner");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Shapes");
|
|
|
|
|
|
|
|
|
|
var response = await owner.PostContractAsync(
|
|
|
|
|
InvitationsUrl(team.TeamId),
|
|
|
|
|
new CreateTeamInvitationRequest(
|
|
|
|
|
Guid.CreateVersion7(), "not an address", TeamMemberRole.Member));
|
|
|
|
|
|
|
|
|
|
await ShouldBeProblemAsync(
|
|
|
|
|
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// A withdrawn invitation stays in the listing rather than vanishing, so the screen can show that it
|
|
|
|
|
/// was withdrawn rather than letting it read as never sent. Withdrawing it twice is 404, for the
|
|
|
|
|
/// reason revoking a device grant gives: a caller driving towards "that invitation will not let
|
|
|
|
|
/// anybody in" can treat 404 as having arrived.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task RevokingAnInvitation_MarksItRevokedAndCannotBeDoneTwice()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("invite-revoke-owner");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Withdrawals");
|
|
|
|
|
var invitation = await InviteAsync(owner, team.TeamId, NewAddress());
|
|
|
|
|
|
|
|
|
|
var revoked = await DeleteAsync(
|
|
|
|
|
owner, InvitationUrl(team.TeamId, invitation.InvitationId));
|
|
|
|
|
|
|
|
|
|
revoked.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
|
|
|
|
|
|
|
|
|
var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);
|
|
|
|
|
|
|
|
|
|
listed.State.ShouldBe(TeamInvitationState.Revoked);
|
|
|
|
|
|
|
|
|
|
var again = await DeleteAsync(owner, InvitationUrl(team.TeamId, invitation.InvitationId));
|
|
|
|
|
|
|
|
|
|
again.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// An invitation is a fact about the team, so any member may read the list — whoever is about to be
|
|
|
|
|
/// handed a vault key needs to see who else is on their way in — but only an admin may write one.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task APlainMember_MayReadInvitationsAndMayNotIssueThem()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("invite-reader-owner", "irowner@example.com");
|
|
|
|
|
var member = await EnrolledClientAsync("invite-reader-member", "irmember@example.com");
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Readable invitations");
|
|
|
|
|
var entry = await LookupAsync(owner, "irmember@example.com");
|
|
|
|
|
|
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
|
|
|
|
|
|
|
|
|
var invitation = await InviteAsync(owner, team.TeamId, NewAddress());
|
|
|
|
|
|
|
|
|
|
var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
|
|
|
|
|
member, InvitationsUrl(team.TeamId));
|
|
|
|
|
|
|
|
|
|
listed.ShouldContain(row => row.InvitationId == invitation.InvitationId);
|
|
|
|
|
|
|
|
|
|
var refused = await member.PostContractAsync(
|
|
|
|
|
InvitationsUrl(team.TeamId),
|
|
|
|
|
new CreateTeamInvitationRequest(
|
|
|
|
|
Guid.CreateVersion7(), NewAddress(), TeamMemberRole.Member));
|
|
|
|
|
|
|
|
|
|
await ShouldBeProblemAsync(refused, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Claiming an invitation at sign-in ----
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The heart of the feature, and the only path by which an invitation becomes anything. There is no
|
|
|
|
|
/// token and no mail: the row says "the next account to sign in with this address joins this team",
|
|
|
|
|
/// and just-in-time provisioning is what reads it. What it creates is a membership and not a key —
|
|
|
|
|
/// the vault key still has to be wrapped to them from a machine that holds one.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task SigningInWithAnInvitedAddress_JoinsTheTeamAndMarksTheInvitationAccepted()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("claim-owner");
|
|
|
|
|
var address = NewAddress();
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Claimed");
|
|
|
|
|
var invitation = await InviteAsync(owner, team.TeamId, address, TeamMemberRole.Member);
|
|
|
|
|
|
|
|
|
|
var (invitee, inviteeUserId) = await SignInAsync(address);
|
|
|
|
|
|
|
|
|
|
(await ReadAsync<IReadOnlyList<TeamSummary>>(invitee, TeamsUrl))
|
|
|
|
|
.Where(row => row.TeamId == team.TeamId)
|
|
|
|
|
.ShouldHaveSingleItem()
|
|
|
|
|
.Role.ShouldBe(TeamMemberRole.Member);
|
|
|
|
|
|
|
|
|
|
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
|
|
|
|
|
owner, MembersUrl(team.TeamId));
|
|
|
|
|
|
|
|
|
|
var joined = members.Where(row => row.UserId == inviteeUserId).ShouldHaveSingleItem();
|
|
|
|
|
|
|
|
|
|
joined.Status.ShouldBe(TeamMemberStatus.Active);
|
|
|
|
|
joined.Role.ShouldBe(TeamMemberRole.Member);
|
|
|
|
|
joined.IsEnrolled.ShouldBeFalse("membership is authorization, and they hold no key yet");
|
|
|
|
|
|
|
|
|
|
var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);
|
|
|
|
|
|
|
|
|
|
listed.State.ShouldBe(TeamInvitationState.Accepted);
|
|
|
|
|
listed.AcceptedAt.ShouldNotBeNull();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <b>The security test this feature stands on.</b> An invitation is authorization: claiming one is
|
|
|
|
|
/// what decides that this server will serve somebody a team's vaults. An address the identity
|
|
|
|
|
/// provider has not vouched for is an address anybody able to obtain a token can name, so an
|
|
|
|
|
/// unverified one must confer nothing — that is the same attack <c>AllowEmailLinking</c> exists to
|
|
|
|
|
/// refuse, arriving by a different door. The failure would be silent by construction: the account is
|
|
|
|
|
/// provisioned either way and every other part of the request succeeds, so nothing but this would
|
|
|
|
|
/// notice that the wrong person had walked into the team.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task AnAddressTheProviderHasNotVerified_ClaimsNothing()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("unverified-owner");
|
|
|
|
|
var address = NewAddress();
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Verified only");
|
|
|
|
|
var invitation = await InviteAsync(owner, team.TeamId, address);
|
|
|
|
|
|
|
|
|
|
var (impostor, _) = await SignInAsync(address, emailVerified: false);
|
|
|
|
|
|
|
|
|
|
(await ReadAsync<IReadOnlyList<TeamSummary>>(impostor, TeamsUrl)).ShouldBeEmpty();
|
|
|
|
|
|
|
|
|
|
var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);
|
|
|
|
|
|
|
|
|
|
listed.State.ShouldBe(TeamInvitationState.Pending);
|
|
|
|
|
listed.AcceptedAt.ShouldBeNull();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// A withdrawn invitation is withdrawn, which the claim path has to honour independently — it reads
|
|
|
|
|
/// the invitation table directly rather than going back through the endpoint that refused.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task SigningInAfterAnInvitationWasWithdrawn_JoinsNothing()
|
|
|
|
|
{
|
|
|
|
|
var owner = await EnrolledClientAsync("withdrawn-owner");
|
|
|
|
|
var address = NewAddress();
|
|
|
|
|
|
|
|
|
|
var team = await CreateTeamAsync(owner, "Withdrawn");
|
|
|
|
|
var invitation = await InviteAsync(owner, team.TeamId, address);
|
|
|
|
|
|
|
|
|
|
await DeleteAsync(owner, InvitationUrl(team.TeamId, invitation.InvitationId));
|
|
|
|
|
|
|
|
|
|
var (invitee, _) = await SignInAsync(address);
|
|
|
|
|
|
|
|
|
|
(await ReadAsync<IReadOnlyList<TeamSummary>>(invitee, TeamsUrl)).ShouldBeEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Vault key grants ----
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// A grant to somebody outside the team is refused. It would be a row that looks like sharing and
|
|
|
|
|
/// does nothing, because the access check will go on refusing them the vault — and a sharing screen
|
|
|
|
@@ -283,11 +899,8 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
GrantSignature: new byte[64],
|
|
|
|
|
GrantedAt: DateTimeOffset.UnixEpoch));
|
|
|
|
|
|
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
|
|
|
|
|
|
|
|
|
var problem = await response.Content.ReadProblemAsync();
|
|
|
|
|
|
|
|
|
|
problem.Code.ShouldBe(ProblemCodes.InvalidVaultGrant);
|
|
|
|
|
await ShouldBeProblemAsync(
|
|
|
|
|
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
@@ -323,6 +936,8 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- The directory and the key log ----
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The directory has no search. Asserting it rather than trusting the implementation, because a
|
|
|
|
|
/// prefix match added later for convenience turns a server that stores addresses in plaintext into a
|
|
|
|
@@ -394,6 +1009,29 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Helpers ----
|
|
|
|
|
|
|
|
|
|
private static string TeamUrl(Guid teamId) => $"{TeamsUrl}/{teamId}";
|
|
|
|
|
|
|
|
|
|
private static string OwnerUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/owner";
|
|
|
|
|
|
|
|
|
|
private static string MembersUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/members";
|
|
|
|
|
|
|
|
|
|
private static string MemberUrl(Guid teamId, Guid userId) => $"{MembersUrl(teamId)}/{userId}";
|
|
|
|
|
|
|
|
|
|
private static string MemberRoleUrl(Guid teamId, Guid userId) =>
|
|
|
|
|
$"{MemberUrl(teamId, userId)}/role";
|
|
|
|
|
|
|
|
|
|
private static string InvitationsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/invitations";
|
|
|
|
|
|
|
|
|
|
private static string InvitationUrl(Guid teamId, Guid invitationId) =>
|
|
|
|
|
$"{InvitationsUrl(teamId)}/{invitationId}";
|
|
|
|
|
|
|
|
|
|
private static string TeamVaultsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/vaults";
|
|
|
|
|
|
|
|
|
|
/// <summary>An address no account holds, uniquified because the container is shared.</summary>
|
|
|
|
|
private static string NewAddress() => $"invitee-{Guid.CreateVersion7():N}@example.com";
|
|
|
|
|
|
|
|
|
|
private async Task<HttpClient> EnrolledClientAsync(string subject, string? email = null)
|
|
|
|
|
{
|
|
|
|
|
var unique = $"{subject}-{Guid.CreateVersion7():N}";
|
|
|
|
@@ -416,6 +1054,24 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
return client;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Signs a brand-new account in, which is what claims any invitation to its address.</summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// No enrollment, because an invitee has no key and the claim path must not need one — requiring it
|
|
|
|
|
/// would be requiring it of exactly the person who cannot yet supply it. Driving <c>/me</c> is what
|
|
|
|
|
/// runs just-in-time provisioning, and provisioning is where the claim happens.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private async Task<(HttpClient Client, Guid UserId)> SignInAsync(
|
|
|
|
|
string email,
|
|
|
|
|
bool emailVerified = true)
|
|
|
|
|
{
|
|
|
|
|
var client = fixture.CreateClientFor(
|
|
|
|
|
$"invitee-{Guid.CreateVersion7():N}", email, emailVerified);
|
|
|
|
|
|
|
|
|
|
var me = await ReadAsync<MeResponse>(client, MeUrl);
|
|
|
|
|
|
|
|
|
|
return (client, me.UserId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Uniquified addresses, keyed on the readable one a test wrote.</summary>
|
|
|
|
|
private readonly Dictionary<string, string> addresses = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
|
|
|
|
@@ -425,7 +1081,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
/// transformation the product does not perform. It is uniquified because the container is shared
|
|
|
|
|
/// across every class in this assembly and the slug is unique deployment-wide.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private Task<TeamSummary> CreateTeamAsync(HttpClient client, string name) =>
|
|
|
|
|
private static Task<TeamSummary> CreateTeamAsync(HttpClient client, string name) =>
|
|
|
|
|
PostAsync<CreateTeamRequest, TeamSummary>(
|
|
|
|
|
client,
|
|
|
|
|
TeamsUrl,
|
|
|
|
@@ -437,11 +1093,11 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
/// opaquely and verifies neither — see docs/crypto.md §6 — so a real seal here would be testing the
|
|
|
|
|
/// crypto library rather than the endpoint.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private async Task<Guid> CreateVaultAsync(HttpClient client, Guid teamId)
|
|
|
|
|
private static async Task<Guid> CreateVaultAsync(HttpClient client, Guid teamId)
|
|
|
|
|
{
|
|
|
|
|
var vault = await PostAsync<CreateTeamVaultRequest, VaultSummary>(
|
|
|
|
|
client,
|
|
|
|
|
$"{TeamsUrl}/{teamId}/vaults",
|
|
|
|
|
TeamVaultsUrl(teamId),
|
|
|
|
|
new CreateTeamVaultRequest(
|
|
|
|
|
Guid.CreateVersion7(),
|
|
|
|
|
"Team vault",
|
|
|
|
@@ -469,11 +1125,37 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
TeamMemberRole role)
|
|
|
|
|
{
|
|
|
|
|
var response = await client.PostContractAsync(
|
|
|
|
|
$"{TeamsUrl}/{teamId}/members", new AddTeamMemberRequest(userId, role));
|
|
|
|
|
MembersUrl(teamId), new AddTeamMemberRequest(userId, role));
|
|
|
|
|
|
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static Task<TeamInvitationSummary> InviteAsync(
|
|
|
|
|
HttpClient client,
|
|
|
|
|
Guid teamId,
|
|
|
|
|
string email,
|
|
|
|
|
TeamMemberRole role = TeamMemberRole.Member) =>
|
|
|
|
|
PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
|
|
|
|
|
client,
|
|
|
|
|
InvitationsUrl(teamId),
|
|
|
|
|
new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, role));
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Filtered by id rather than taken from a position in the list, because the container is shared and
|
|
|
|
|
/// every other class's invitations are in the same table. A count over the whole listing would pass
|
|
|
|
|
/// or fail depending on what else ran.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private static async Task<TeamInvitationSummary> FindInvitationAsync(
|
|
|
|
|
HttpClient client,
|
|
|
|
|
Guid teamId,
|
|
|
|
|
Guid invitationId)
|
|
|
|
|
{
|
|
|
|
|
var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
|
|
|
|
|
client, InvitationsUrl(teamId));
|
|
|
|
|
|
|
|
|
|
return listed.Where(row => row.InvitationId == invitationId).ShouldHaveSingleItem();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static async Task<TResponse> PostAsync<TRequest, TResponse>(
|
|
|
|
|
HttpClient client,
|
|
|
|
|
string url,
|
|
|
|
@@ -488,11 +1170,32 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|
|
|
|
|
|
|
|
|
private static async Task<T> ReadAsync<T>(HttpClient client, string url)
|
|
|
|
|
{
|
|
|
|
|
var response = await client.GetAsync(
|
|
|
|
|
new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken);
|
|
|
|
|
var response = await GetAsync(client, url);
|
|
|
|
|
|
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
|
|
|
|
|
|
return (await response.Content.ReadContractAsync<T>())!;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static Task<HttpResponseMessage> GetAsync(HttpClient client, string url) =>
|
|
|
|
|
client.GetAsync(new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken);
|
|
|
|
|
|
|
|
|
|
private static Task<HttpResponseMessage> DeleteAsync(HttpClient client, string url) =>
|
|
|
|
|
client.DeleteAsync(new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken);
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Asserts on the <c>code</c> extension and never on the prose, for the reason
|
|
|
|
|
/// <see cref="JsonProblem"/> gives: the code is the contract and the wording is not.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private static async Task ShouldBeProblemAsync(
|
|
|
|
|
HttpResponseMessage response,
|
|
|
|
|
HttpStatusCode expectedStatus,
|
|
|
|
|
string expectedCode)
|
|
|
|
|
{
|
|
|
|
|
response.StatusCode.ShouldBe(expectedStatus);
|
|
|
|
|
|
|
|
|
|
var problem = await response.Content.ReadProblemAsync();
|
|
|
|
|
problem.ShouldNotBeNull();
|
|
|
|
|
problem.Code.ShouldBe(expectedCode);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|