Public Access
M3 built teams and stopped short of the two operations that decide who controls one. Both were written down as refusals rather than omissions: ADR 0009 listed ownership transfer under "deliberately not built", and design-import-gaps said an invitation needed "a token with a lifetime and an outbound mail path". One of those reasons had expired and the other never applied — an invitation does not need a token if it is not a thing anybody presents. Handing a team over is one write. The member you name becomes owner and you become an admin, in a single transaction, because ownership is sole: promoting first leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody left with the authority to finish a transfer that stopped in the middle. That is also why it is not two calls to the role endpoint, which refuses Owner outright. The outgoing owner is demoted rather than removed — removing them would revoke their vault key grants and flag every team vault for rekey, which is a far larger act than the one asked for, and somebody handing over a team is usually staying in it. It unblocks the thing that was impossible before: an owner can now leave, by handing the team on first. An invitation is a standing instruction rather than a message. This server has no outbound mail path, so nothing is sent and there is nothing for the invitee to present. The row says the next account signing in with that address joins this team at this role, and telling them to sign in is the caller's job over a channel this server does not carry. A link nobody can deliver would be worse than none. It lives in its own table rather than becoming a membership with MembershipStatus.Invited, and that member stays unwritten for the reason it always was: team_membership.user_id is not nullable and carries a foreign key, so somebody who has never signed in has nothing for that row to point at. Widening it would make the unique index on (team, user) meaningless, because PostgreSQL counts every NULL as distinct. Verification is the security boundary, and nothing in this server read it before. A claim requires the access token to assert email_verified. An invitation decides what the server will serve, so one claimable by anybody able to obtain a token carrying somebody else's address is a way into a team — which is precisely the attack OidcOptions.AllowEmailLinking exists to refuse, and it would have been reintroduced by the back door. There is deliberately no setting that relaxes it: a flag that exists is one somebody turns on for the afternoon their provider is misconfigured. Absence is refused rather than trusted, and logged, because a provider that never sends the claim otherwise leaves every invitation pending with nothing anywhere saying why. Claiming happens at just-in-time provisioning and again on an hourly sweep. The sweep is what makes it recoverable rather than one-shot — an invitation issued between an account being created and that person next signing in would otherwise be stranded for ever — and it shares its rate with the last-seen write because both are housekeeping nobody is waiting on. Archiving is refused while a team owns a vault, and that refusal is the end of the road rather than a step on it. A team vault is readable because of membership, so archiving one that still owned vaults would take them away from everybody holding a key, including the caller, quietly and all at once. Nothing in this product deletes a vault, so no order of operations gets past it today — which is stated with a count of what is in the way, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and a quiet removal is not. It is owner-only, as handing over is; renaming is not, because a rename is visible to everybody and reversible by anybody who can do it. The slug is not renameable at all: it is unique only among live teams, so a rename could take one an archived team is still holding, and that team could then never be restored. LAST ACTIVE is real and coarse on purpose. UserAccount.LastSeenAtUtc is refreshed on ordinary authenticated requests, at most once per account per hour, through ExecuteUpdateAsync — user_account carries the xmin concurrency token, so a read-then-write on the hot path would start losing races between one user's own overlapping requests. An hour is the granularity the question is actually asked at, and the interface draws it to the day rather than the minute so it does not read as a precision that is not there. The remarks in Contracts and in the view model that argued at length for the column's absence are rewritten rather than extended; both had become false. Two endpoints already existed and nothing called them. ChangeTeamMemberRole and ListVaultGrants have been reachable since M3. The role picker refuses Owner itself rather than letting the server do it, since the interface already knew the rule; the key-holder list sits under the vault rather than beside the member, because a grant is per vault and a count on a member row would imply per-item sharing, which is M5. It lists withdrawn and stale grants and says which they are — a list that dropped them would show a departed colleague as merely absent rather than as somebody whose key was taken away — and staleness is decided by comparing generations, since a grant can be Active and still open nothing. ADD MEMBER stopped being a dead end. An address the directory did not know used to end at a sentence telling the user their colleague had to sign in first. It invites them instead, from the same button, because which of the two applies is a fact about the server's account table rather than about what the user is doing; which one happened is reported afterwards, because that decides what they do next. An address that merely has an account is invited rather than refused: refusing would have made the endpoint an oracle for which addresses have accounts here, answerable by anybody willing to create a team first. The phone has a TEAMS screen, behind MORE, and it is the reverse of every other row in design-import-gaps: a shipped screen the design had no slot for. It is there because an invitation is claimed by signing in, so somebody told they are now in a team is at least as likely to be holding a phone — and a membership visible only on a head they never installed is one they cannot see. It draws SHARE KEY and nothing that takes something away: wrapping a key is the one act on that screen a server cannot perform at all, and the desktop guards its revocations with a tooltip, which is a control a touch screen cannot show. Two defects were found by an adversarial pass and both were green against the whole suite at the time. The owner-only check on archiving and handing over had been weakened to the admin check while their messages and comments still said owner — and since nothing behind the archive endpoint re-checks it, an admin the owner had promoted could have archived the team out from under them. And the rename endpoint built its response with a hardcoded Owner role, so an admin who renamed a team was handed a summary claiming they owned it, and a client trusting that instead of re-listing would have offered them the two owner-only buttons the server then refuses. The new table gets its constraints tested rather than merely migrated: live uniqueness per (team, address), the citext proof that an address typed by a person matches one cased by a provider, and reissue after both revocation and acceptance. The teams screen gets its first entries in the layout suite, at the minimum window with every list populated and with each of the two states that cover half of it — it had none, and it just grew four sections and a second line in the member row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1202 lines
51 KiB
C#
1202 lines
51 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>
|
|
/// 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);
|
|
}
|
|
}
|