Files
DodoSSH/tests/DodoSSH.Api.Tests/TeamEndpointTests.cs
T
jaap-jan aa06868b1e
ci / build and test (push) Successful in 1m30s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 33s
Add the member the directory cannot see, rather than inviting them
ADD MEMBER quietly issued an invitation instead of adding anybody, for everyone
who had signed in here and not yet enrolled. The screen told them that address
had no account, the members list did not change, and the person only actually
joined on the next hourly sweep.

The client decided whether an address had an account by asking the public-key
directory, and the directory answers a narrower question than that. It drops
every account with no current key — deliberately, because an entry exists to be
wrapped to and one carrying no key is a check a caller forgets exactly once. An
account exists from its owner's first authenticated request and publishes
nothing until they choose a passphrase on their own machine, so every account is
missing from the directory for that whole window and some indefinitely. A miss
there is not an absent account, and reading it as one was the bug.

The server would have taken the add. TeamService.AddMemberAsync only requires
the account row, and TeamMemberSummary.IsEnrolled exists precisely so a member
with no key can be listed — added on Monday, enrolled on Tuesday. The client
never asked.

So the directory is still asked first and the miss is retried as an add by
address, and only a server saying there is no such account reaches the
invitation. AddTeamMemberRequest gained an Email used when UserId is empty. The
lookup-first ordering is kept because it is load-bearing for sharing and not for
this: the key verified before a vault key is wrapped is the one the lookup
returned, and nothing is wrapped by adding somebody. That is why resolving the
address server-side is safe here and would not be there.

NoSuchAccount is its own code rather than folded into InvalidTeam, because it is
the one add failure the caller can act on unprompted — there is nobody to add,
so invite them — and a code shared with a rejected role would leave them
guessing which had happened. It does answer whether an address has an account
here, which CreateTeamInvitationRequest deliberately does not. That is the
property traded for the fix; the exposure is bounded by the admin check the add
already needed, and it is the same fact the member list shows a moment later.
Adding by a user id that does not exist now answers 404 no-such-account rather
than 400 invalid-team, and nothing depended on the old pairing.

The two silent returns are gone. Offline and no-team-selected set nothing and
returned, so those failures were visible only as a flicker of the busy flag —
which reads as a button that does nothing at all. The success line reads the
enrollment flag too, because pointing an unenrolled member at SHARE KEY is
pointing at a button that will refuse; their row already says it holds no key.

Why nothing caught it. FakeVaultServer had one list, so it could not tell an
account that does not exist from one that exists and has not enrolled — the
distinction this whole path turns on — and every account it knew was enrolled by
construction. It grows an accounts list beside the directory and reports
IsEnrolled from whether the directory has them, rather than hardcoding true. The
regression test asserts Invitations is empty, which is what fails against the
old behaviour. Four tests: that pair in the shell suite, and in the API suite an
unenrolled account added by address after its own directory lookup comes back
empty, and an unknown address refused under the new code. 1495 tests pass.
2026-08-03 17:04:03 +02:00

1265 lines
54 KiB
C#

using System.Net;
using System.Net.Http.Json;
using DodoSSH.Contracts;
namespace DodoSSH.Api.Tests;
/// <summary>
/// Teams, membership, invitations and the vault key grants that make a team vault readable.
/// </summary>
/// <remarks>
/// <para>
/// The half of M3 that runs on the server, which is the half that decides <em>what will be served</em>.
/// Whether a member can decrypt what they are served is decided by holding a key, and no test here can
/// assert it — that lives in the client suite, where a key exists. The two are separate on purpose and
/// these tests are written to keep them separate: none of them checks that a wrapped key is right,
/// because the server cannot.
/// </para>
/// <para>
/// The cases worth having are the ones where a mistake would be invisible. A member removed but still
/// served; a viewer allowed to push; a vault visible to a team it does not belong to; a grant accepted
/// 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()
{
var client = await EnrolledClientAsync("team-owner");
var team = await CreateTeamAsync(client, "Platform");
team.Role.ShouldBe(TeamMemberRole.Owner);
team.MemberCount.ShouldBe(1);
team.VaultCount.ShouldBe(0);
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(client, TeamsUrl);
listed.ShouldContain(row => row.TeamId == team.TeamId);
}
/// <remarks>
/// The same body twice, as a client whose response was lost would send it. Enrollment behaves this way
/// and a team create has the same shape — a client-chosen id — so it has to behave the same or a lost
/// response leaves somebody with two teams under one name.
/// </remarks>
[Fact]
public async Task RepeatingACreate_ReturnsTheSameTeamRatherThanASecondOne()
{
var client = await EnrolledClientAsync("team-retry");
var request = new CreateTeamRequest(
Guid.CreateVersion7(), "Retry", $"retry-{Guid.CreateVersion7():N}", null);
var first = await PostAsync<CreateTeamRequest, TeamSummary>(client, TeamsUrl, request);
var second = await PostAsync<CreateTeamRequest, TeamSummary>(client, TeamsUrl, request);
second.TeamId.ShouldBe(first.TeamId);
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(client, TeamsUrl);
listed.Count(row => row.TeamId == first.TeamId).ShouldBe(1);
}
[Fact]
public async Task ASlugAlreadyInUse_IsRefusedWithItsOwnCode()
{
var client = await EnrolledClientAsync("team-slug");
var slug = $"taken-{Guid.CreateVersion7():N}";
await PostAsync<CreateTeamRequest, TeamSummary>(
client, TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "First", slug, null));
var response = await client.PostContractAsync(
TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "Second", slug, null));
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.TeamSlugTaken);
}
/// <remarks>
/// A team somebody is not in answers 404, not 403. Distinguishing them would let a caller confirm
/// which team ids exist, and team ids travel in URLs.
/// </remarks>
[Fact]
public async Task ATeamTheCallerIsNotIn_IsIndistinguishableFromOneThatDoesNotExist()
{
var owner = await EnrolledClientAsync("team-private-owner");
var stranger = await EnrolledClientAsync("team-private-stranger");
var team = await CreateTeamAsync(owner, "Private");
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
/// design — the server can grant access to the ciphertext and cannot grant the ability to read it.
/// </remarks>
[Fact]
public async Task AnAddedMember_SeesTheTeamVaultWithNoKeyUntilSomebodyWrapsOne()
{
var owner = await EnrolledClientAsync("grant-owner", "owner@example.com");
var member = await EnrolledClientAsync("grant-member", "member@example.com");
var team = await CreateTeamAsync(owner, "Sharing");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "member@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
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");
vault.WrappedVaultKey.ShouldBeNull("and it is not what makes it readable");
vault.IsPersonal.ShouldBeFalse();
vault.TeamId.ShouldBe(team.TeamId);
}
/// <remarks>
/// <para>
/// An account exists from its owner's first authenticated request and publishes no key until they
/// enroll, and the directory omits it for that whole window — deliberately, because an entry exists
/// to be wrapped to. So the lookup is asserted empty first: that is not a missing account, and a
/// caller that read it as one would invite an address that already has one.
/// </para>
/// <para>
/// Adding by address is what covers the gap, and the member it produces says <c>IsEnrolled</c>
/// false. Membership is authorization and grants nothing readable, so there is nothing inconsistent
/// about a member with no key — it is the state everybody passes through.
/// </para>
/// </remarks>
[Fact]
public async Task AnAccountThatHasNotEnrolled_CanBeAddedByAddressThoughTheDirectoryOmitsIt()
{
var owner = await EnrolledClientAsync("unenrolled-owner", "uowner@example.com");
var address = NewAddress();
var (_, userId) = await SignInAsync(address);
var team = await CreateTeamAsync(owner, "Newcomers");
var found = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
owner, $"/api/v1/directory?email={Uri.EscapeDataString(address)}");
found.ShouldBeEmpty("they have published no key, so there is nothing to wrap to");
var member = await PostAsync<AddTeamMemberRequest, TeamMemberSummary>(
owner,
MembersUrl(team.TeamId),
new AddTeamMemberRequest(Guid.Empty, TeamMemberRole.Member, address));
member.UserId.ShouldBe(userId);
member.IsEnrolled.ShouldBeFalse();
member.Role.ShouldBe(TeamMemberRole.Member);
var listed = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
owner, MembersUrl(team.TeamId));
listed.ShouldContain(row => row.UserId == userId && !row.IsEnrolled);
}
/// <remarks>
/// The other side of it. An address with no account is refused under its own code rather than the
/// general one, because the caller can act on it unprompted — there is nobody to add, so invite
/// them — and a code shared with a rejected role would leave them guessing which had happened.
/// </remarks>
[Fact]
public async Task AddingAnAddressWithNoAccount_IsRefusedWithItsOwnCode()
{
var owner = await EnrolledClientAsync("no-account-owner", "naowner@example.com");
var team = await CreateTeamAsync(owner, "Nobody");
var response = await owner.PostContractAsync(
MembersUrl(team.TeamId),
new AddTeamMemberRequest(Guid.Empty, TeamMemberRole.Member, NewAddress()));
await ShouldBeProblemAsync(
response, HttpStatusCode.NotFound, ProblemCodes.NoSuchAccount);
}
/// <remarks>
/// A viewer may read the vault and may not write to it. The failure this guards is the quiet one: a
/// role that resolved to the wrong flags would let somebody who was added to look at a vault change
/// what everybody else connects with.
/// </remarks>
[Fact]
public async Task AViewer_MayPullAndMayNotPush()
{
var owner = await EnrolledClientAsync("viewer-owner", "vowner@example.com");
var viewer = await EnrolledClientAsync("viewer-member", "viewer@example.com");
var team = await CreateTeamAsync(owner, "Read only");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "viewer@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Viewer);
var pull = await viewer.PostContractAsync(
$"/api/v1/vaults/{vaultId}/sync/pull", new SyncPullRequest(null, null, null));
pull.StatusCode.ShouldBe(HttpStatusCode.OK);
var push = await viewer.PostContractAsync(
$"/api/v1/vaults/{vaultId}/sync/push", new SyncPushRequest([]));
await ShouldBeProblemAsync(push, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
}
/// <remarks>
/// And the reverse, which is what removal has to mean: the vault stops being served at all. Note what
/// is <em>not</em> asserted — that they have forgotten anything. They have not, and ADR 0001 says so.
/// </remarks>
[Fact]
public async Task ARemovedMember_StopsBeingServedTheTeamsVault()
{
var owner = await EnrolledClientAsync("removal-owner", "rowner@example.com");
var member = await EnrolledClientAsync("removal-member", "rmember@example.com");
var team = await CreateTeamAsync(owner, "Departures");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "rmember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var before = await ReadAsync<MeResponse>(member, MeUrl);
before.Vaults.ShouldContain(summary => summary.VaultId == vaultId);
var removed = await DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId));
removed.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var after = await ReadAsync<MeResponse>(member, MeUrl);
after.Vaults.ShouldNotContain(summary => summary.VaultId == vaultId);
var pull = await member.PostContractAsync(
$"/api/v1/vaults/{vaultId}/sync/pull", new SyncPullRequest(null, null, null));
pull.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
/// <remarks>
/// Removing a member leaves the vault flagged for rekey, which is a promise the server records and
/// cannot keep on its own: rekeying re-wraps every item's data key and only a client holding the
/// current one can do that. The flag is what the interface reads to say so; M5 is what acts on it.
/// </remarks>
[Fact]
public async Task RemovingAMember_FlagsTheTeamsVaultsForRekey()
{
var owner = await EnrolledClientAsync("rekey-owner", "kowner@example.com");
await EnrolledClientAsync("rekey-member", "kmember@example.com");
var team = await CreateTeamAsync(owner, "Rekeys");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "kmember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
await DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId));
var grants = await ReadAsync<VaultGrantsResponse>(owner, $"/api/v1/vaults/{vaultId}/grants");
grants.RekeyRequired.ShouldBeTrue();
}
/// <remarks>
/// 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()
{
var owner = await EnrolledClientAsync("sole-owner", "sole@example.com");
var team = await CreateTeamAsync(owner, "Sole");
var me = await ReadAsync<MeResponse>(owner, MeUrl);
var response = await DeleteAsync(owner, MemberUrl(team.TeamId, me.UserId));
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
/// listing a grant whose holder cannot fetch anything is worse than an error.
/// </remarks>
[Fact]
public async Task AGrantToANonMember_IsRefused()
{
var owner = await EnrolledClientAsync("outsider-owner", "oowner@example.com");
await EnrolledClientAsync("outsider", "outsider@example.com");
var team = await CreateTeamAsync(owner, "Closed");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "outsider@example.com");
var response = await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/grants",
new IssueVaultGrantRequest(
entry.UserId,
entry.Fingerprint,
KeyGeneration: 1,
WrappedVaultKey: new byte[110],
KeyLogHead: new byte[32],
GrantSignature: new byte[64],
GrantedAt: DateTimeOffset.UnixEpoch));
await ShouldBeProblemAsync(
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant);
}
/// <remarks>
/// A fingerprint that is not the recipient's current key is refused. The server cannot tell whether
/// the wrap contains the right key — nothing on that machine can — but it can tell that this grant
/// was made for a key nobody holds, which would otherwise surface at the far end days later as a tag
/// failure indistinguishable from corruption.
/// </remarks>
[Fact]
public async Task AGrantForAKeyTheRecipientDoesNotHold_IsRefused()
{
var owner = await EnrolledClientAsync("stale-owner", "sowner@example.com");
await EnrolledClientAsync("stale-member", "smember@example.com");
var team = await CreateTeamAsync(owner, "Stale");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "smember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var response = await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/grants",
new IssueVaultGrantRequest(
entry.UserId,
RecipientKeyFingerprint: new byte[32],
KeyGeneration: 1,
WrappedVaultKey: new byte[110],
KeyLogHead: new byte[32],
GrantSignature: new byte[64],
GrantedAt: DateTimeOffset.UnixEpoch));
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
/// way to enumerate an organisation's staff.
/// </remarks>
[Fact]
public async Task TheDirectory_MatchesAnExactAddressAndNothingElse()
{
var client = await EnrolledClientAsync("directory-self", "findme@example.com");
var address = addresses["findme@example.com"];
var exact = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
client, $"/api/v1/directory?email={Uri.EscapeDataString(address)}");
exact.Count.ShouldBe(1);
// Case-insensitive, because the column is citext and two addresses differing only in case are
// one account. That is a match, not a search.
var cased = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
client, $"/api/v1/directory?email={Uri.EscapeDataString(address.ToUpperInvariant())}");
cased.Count.ShouldBe(1);
// The address with its last character removed. A directory that answered this would be a way to
// walk an organisation's staff list out of a server that stores addresses in plaintext.
var prefix = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
client, $"/api/v1/directory?email={Uri.EscapeDataString(address[..^1])}");
prefix.ShouldBeEmpty();
}
/// <remarks>
/// The key log has to verify from genesis with the hashes the server publishes, because that is the
/// whole of what a client can check. A chain that only the server could reproduce would make key
/// transparency a claim rather than a mechanism.
/// </remarks>
[Fact]
public async Task TheKeyLog_ChainsFromGenesisWithTheHashesItPublishes()
{
var client = await EnrolledClientAsync("keylog-reader", "keylog@example.com");
var page = await ReadAsync<KeyLogPage>(client, "/api/v1/keylog?after=0");
page.Entries.ShouldNotBeEmpty();
var previous = Crypto.KeyLogChain.CreateGenesisPreviousHash();
foreach (var entry in page.Entries)
{
entry.PreviousHash.ShouldBe(previous);
Crypto.KeyLogChain.ComputeEntryHash(
entry.PreviousHash,
entry.UserId,
entry.Generation,
entry.EncryptionPublicKey,
entry.SigningPublicKey,
entry.StatementSignature,
entry.CreatedAt).ShouldBe(entry.Hash);
previous = entry.Hash;
}
// And the head the page reports is the last link, or a client that paged to the end could not
// tell whether it had seen the whole log.
if (!page.HasMore)
{
page.Head.ShouldBe(previous);
}
}
// ---- 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}";
var address = email is null ? null : $"{Guid.CreateVersion7():N}-{email}";
using var enrollment = new TestEnrollment(fixture.IdentityProvider, unique, address);
var client = fixture.CreateClientFor(unique, address);
var response = await client.PostContractAsync(EnrollUrl, enrollment.Build());
response.EnsureSuccessStatusCode();
// The address is remembered on the client so a later directory lookup can name it: the tests
// uniquify addresses so that runs against a shared container cannot collide.
if (address is not null)
{
addresses[email!] = address;
}
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);
/// <remarks>
/// The slug is generated rather than derived from the name, because a slug is lowercase letters,
/// digits and hyphens and a display name is not — deriving one would make these tests depend on a
/// 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 static Task<TeamSummary> CreateTeamAsync(HttpClient client, string name) =>
PostAsync<CreateTeamRequest, TeamSummary>(
client,
TeamsUrl,
new CreateTeamRequest(
Guid.CreateVersion7(), name, $"team-{Guid.CreateVersion7():N}", null));
/// <remarks>
/// The wrapped key and the signature are the right shape and nothing more. The server stores both
/// 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 static async Task<Guid> CreateVaultAsync(HttpClient client, Guid teamId)
{
var vault = await PostAsync<CreateTeamVaultRequest, VaultSummary>(
client,
TeamVaultsUrl(teamId),
new CreateTeamVaultRequest(
Guid.CreateVersion7(),
"Team vault",
WrappedVaultKey: new byte[110],
GrantSignature: new byte[64],
GrantedAt: DateTimeOffset.UnixEpoch));
return vault.VaultId;
}
private async Task<DirectoryEntry> LookupAsync(HttpClient client, string email)
{
var address = addresses.GetValueOrDefault(email, email);
var found = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
client, $"/api/v1/directory?email={Uri.EscapeDataString(address)}");
return found.ShouldHaveSingleItem();
}
private static async Task AddMemberAsync(
HttpClient client,
Guid teamId,
Guid userId,
TeamMemberRole role)
{
var response = await client.PostContractAsync(
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,
TRequest body)
{
var response = await client.PostContractAsync(url, body);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadContractAsync<TResponse>())!;
}
private static async Task<T> ReadAsync<T>(HttpClient client, string url)
{
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);
}
}