Public Access
The teams screen listed teams that owned vaults, so sharing four servers with two
colleagues meant creating a team, then a vault inside it, then wrapping a key.
Two of those three steps are about a concept nobody arrives wanting. The screen
now lists vaults: naming one creates the membership list that carries it, named
after the vault and owned by you, and members, invitations, roles, hand-over and
key holders all hang off the vault they apply to.
Nothing on the server moved. VaultAccessService still resolves a shared vault
through team_membership and every membership call still names a team id — what
went is the requirement that anybody make one. The split the whole design rests
on is untouched and is still what the screen is built around: adding somebody
authorises the server to serve them, and only a machine holding the key can make
the vault readable. ADR 0009 keeps its decision and gains an addendum recording
which half of it a person is now asked about.
The one place the team resurfaces is a membership list carrying several vaults,
which this screen cannot produce and does not hide: the members section says so,
because "adding somebody here adds them there" is precisely the fact a
vault-shaped screen is in a position to conceal.
Two things left the interface and one arrived. Creating a team is gone, and so is
archiving one — it was only ever possible for a team owning no vaults, and a
screen whose rows are vaults has no row for one, so the button would have been
unreachable or always refused. The endpoint is unchanged and the screen states
the limit instead, since a vault cannot be deleted at all. The exception is a
create whose second call failed: cancelling that form archives the membership
list it left behind, which is a deliberate departure from this client's rule
against tidying up on the user's behalf, made because nothing else can reach it.
What arrived is PUT /api/v1/vaults/{id}. Without it the screen loses its only
editing action, since renaming the team behind a vault is invisible to everybody
who was never shown the team. It is gated on PermissionFlags.Admin — the line
UpdateTeamEndpoint already draws, because a name is what everybody in the vault
sees it called rather than part of its contents — and it renames the owning team
with it when that team carries nothing else, so the row an operator reads and the
name a user says cannot drift apart. The slug never moves, for the reason it does
not move on a team rename. The session edits its cached vault row rather than
replacing it with the response, which deliberately carries no wrapped key.
The host editor now asks which vault a host goes into, beside the name, while
adding and only where there is more than one vault to write to. It is a second
picker rather than the keychain screen's reused, and the two selections are
separate on purpose: that one is a standing preference about where new items go,
this is a field of the host in front of you, and binding both to one selection
would mean a click on the other screen could move a half-typed host. An existing
host is not offered it at all rather than offered it disabled — the two vaults
are encrypted under different keys, so moving an item is a delete and a retype.
That forced a fix worth naming. The group picker was built from the active
vault's groups whatever vault the host was being filed into, so a host put in a
shared vault could be filed under a group only its author can resolve — a
colleague would see it filed under nothing, which is the quietest kind of wrong.
Groups are now kept per vault and the picker follows the vault choice.
Two renames, because the pair they would otherwise have made is a bug farm:
ShellScreen.Vault became Keychain and VaultScreen became KeychainScreen, which is
what the rail has always labelled that screen, leaving Vault for one vault's
contents and Vaults for the vaults themselves. The enum values are unchanged;
NavRail.axaml writes them as x:Static literals.
1536 tests pass, seven more than before. Five are new on the server — the rename
endpoint's success, the team it does and does not take with it, the two refusals
and the empty name — and the client suite gains six and folds four together,
having lost the two about archiving a team.
1395 lines
59 KiB
C#
1395 lines
59 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);
|
|
}
|
|
|
|
// ---- Renaming a vault ----
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The rename the vaults screen offers, and the assertion that matters is the second one: the team is
|
|
/// renamed with the vault when it owns nothing else. A vault made from that screen gets a team of its
|
|
/// own that nobody was ever shown, so a rename that moved only the vault would leave the operator, the
|
|
/// logs and the database naming it something no user recognises.
|
|
/// </para>
|
|
/// <para>
|
|
/// The slug is asserted unchanged in the same breath. It is unique only among live teams, so a rename
|
|
/// that moved it could take one an archived team is still holding — the same limit
|
|
/// <c>UpdateTeamRequest</c> records.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RenamingAVault_RenamesTheTeamBehindItAndLeavesItsSlugAlone()
|
|
{
|
|
var owner = await EnrolledClientAsync("vault-rename-owner");
|
|
|
|
var team = await CreateTeamAsync(owner, "Platform secrets");
|
|
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
|
|
|
var response = await owner.PutContractAsync(
|
|
VaultUrl(vaultId), new UpdateVaultRequest("Platform"));
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var renamed = (await response.Content.ReadContractAsync<VaultSummary>())!;
|
|
|
|
renamed.Name.ShouldBe("Platform");
|
|
|
|
var me = await ReadAsync<MeResponse>(owner, MeUrl);
|
|
|
|
me.Vaults.Single(vault => vault.VaultId == vaultId).Name.ShouldBe("Platform");
|
|
|
|
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
|
|
var after = listed.Single(row => row.TeamId == team.TeamId);
|
|
|
|
after.Name.ShouldBe("Platform");
|
|
after.Slug.ShouldBe(team.Slug);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A team carrying several vaults has a name of its own that somebody chose, so renaming one of its
|
|
/// vaults must not take it. This is the arrangement the vaults screen cannot make and does not hide;
|
|
/// the server draws the same line.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RenamingOneOfSeveralVaults_LeavesTheTeamsOwnNameAlone()
|
|
{
|
|
var owner = await EnrolledClientAsync("vault-rename-shared-owner");
|
|
|
|
var team = await CreateTeamAsync(owner, "Platform Engineering");
|
|
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
|
|
|
await CreateVaultAsync(owner, team.TeamId);
|
|
|
|
var response = await owner.PutContractAsync(
|
|
VaultUrl(vaultId), new UpdateVaultRequest("Production"));
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
|
|
|
|
listed.Single(row => row.TeamId == team.TeamId).Name.ShouldBe("Platform Engineering");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Admin rather than Write, and the line is the one the team rename draws: a name is what everybody in
|
|
/// the vault sees it called, so a member who may add hosts to it may not rename it out from under them.
|
|
/// A member is refused with 403 rather than 404 because the vault is visible to them, so naming the
|
|
/// reason leaks nothing.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task APlainMember_CannotRenameAVaultTheyCanWriteTo()
|
|
{
|
|
var owner = await EnrolledClientAsync("vault-rename-limits-owner", "vrowner@example.com");
|
|
var member = await EnrolledClientAsync("vault-rename-limits-member", "vrmember@example.com");
|
|
|
|
var team = await CreateTeamAsync(owner, "Limits");
|
|
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
|
var entry = await LookupAsync(owner, "vrmember@example.com");
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
|
|
|
var response = await member.PutContractAsync(
|
|
VaultUrl(vaultId), new UpdateVaultRequest("Theirs now"));
|
|
|
|
await ShouldBeProblemAsync(response, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// An outsider gets 404 rather than 403, which is the rule <c>IVaultAccessService</c> states: a
|
|
/// distinct "exists but forbidden" answer is an existence oracle for other tenants' vault ids.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RenamingSomebodyElsesVault_IsNotFound()
|
|
{
|
|
var owner = await EnrolledClientAsync("vault-rename-outsider-owner");
|
|
var outsider = await EnrolledClientAsync("vault-rename-outsider");
|
|
|
|
var team = await CreateTeamAsync(owner, "Private");
|
|
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
|
|
|
var response = await outsider.PutContractAsync(
|
|
VaultUrl(vaultId), new UpdateVaultRequest("Mine now"));
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// An empty name is refused rather than stored, because a vault has to be pickable by name before
|
|
/// anything in it is decrypted — one called nothing is one nobody can choose.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RenamingAVaultToNothing_IsRefused()
|
|
{
|
|
var owner = await EnrolledClientAsync("vault-rename-empty-owner");
|
|
|
|
var team = await CreateTeamAsync(owner, "Named");
|
|
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
|
|
|
var response = await owner.PutContractAsync(VaultUrl(vaultId), new UpdateVaultRequest(" "));
|
|
|
|
await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeam);
|
|
}
|
|
|
|
// ---- 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";
|
|
|
|
private static string VaultUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}";
|
|
|
|
/// <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);
|
|
}
|
|
}
|