Public Access
- Pull hydrated every historical change entry with the item's current ciphertext under that entry's old revision. The AAD binds the revision, so a client opened it at the wrong version, failed, and parked its own pending edit as undecryptable. Entries an item has since moved past are left out; the later entry carries the same payload under the right number, and a fresh device no longer downloads an item once per edit it ever had. - Deleting a team's last vault archived the team whoever deleted it, so an admin could archive it out from under its owner. Only the owner's delete archives it now, and TeamService.ArchiveAsync checks. - UserStatus was checked nowhere on the request path. ActiveAccountRequirement on both policies answers 403 account-not-active for a suspended or deprovisioned account. - The hourly last-seen update moves xmin under the tracked user, and enrolment then saved that user against the stale token: a 500 for anybody enrolling more than an hour after first signing in. The entity is reloaded after the update.
1522 lines
65 KiB
C#
1522 lines
65 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using DodoSSH.Contracts;
|
|
|
|
namespace DodoSSH.Api.Tests;
|
|
|
|
/// <summary>
|
|
/// Teams, membership 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. A team that could be joined by
|
|
/// whoever turns up holding a token asserting an address is a team anybody who can obtain such a token
|
|
/// is in, 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>
|
|
/// <para>
|
|
/// Deleting a vault, and the three things it has to be true of at once: it leaves everybody's list,
|
|
/// every key to it is withdrawn, and the team made to carry it goes with it. The last is the mirror of
|
|
/// the rename above — that team was never shown to anybody, so leaving it behind would leave a
|
|
/// membership list nothing in the interface can reach or remove.
|
|
/// </para>
|
|
/// <para>
|
|
/// The member's list is checked as well as the owner's, because "gone" that is only true for the
|
|
/// person who pressed it is the failure worth catching.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DeletingAVault_TakesItFromEveryMemberAndArchivesTheTeamBehindIt()
|
|
{
|
|
var owner = await EnrolledClientAsync("vault-delete-owner", "vdowner@example.com");
|
|
var member = await EnrolledClientAsync("vault-delete-member", "vdmember@example.com");
|
|
|
|
var team = await CreateTeamAsync(owner, "Short lived");
|
|
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
|
var entry = await LookupAsync(owner, "vdmember@example.com");
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
|
|
|
await owner.PostContractAsync(
|
|
$"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation: 1));
|
|
|
|
(await ReadAsync<MeResponse>(member, MeUrl)).Vaults
|
|
.ShouldContain(vault => vault.VaultId == vaultId);
|
|
|
|
var response = await DeleteAsync(owner, VaultUrl(vaultId));
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
|
|
|
(await ReadAsync<MeResponse>(owner, MeUrl)).Vaults
|
|
.ShouldNotContain(vault => vault.VaultId == vaultId);
|
|
|
|
(await ReadAsync<MeResponse>(member, MeUrl)).Vaults
|
|
.ShouldNotContain(vault => vault.VaultId == vaultId);
|
|
|
|
// Gone the way a vault is gone, rather than merely unlisted: every call naming it answers 404.
|
|
(await GetAsync(owner, $"/api/v1/vaults/{vaultId}/grants"))
|
|
.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
|
|
|
(await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl))
|
|
.ShouldNotContain(row => row.TeamId == team.TeamId, "the team existed to carry that vault");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The archive endpoint refuses an admin, and deleting a team's last vault archives the team too — so
|
|
/// without the same rule there, an admin could archive the team out from under its owner one vault at a
|
|
/// time, revoking the owner's membership on the way. The delete is theirs to make; the team is not.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AnAdminDeletingTheLastVault_DeletesItButLeavesTheTeamToItsOwner()
|
|
{
|
|
var owner = await EnrolledClientAsync("vault-delete-admin-owner", "vdaowner@example.com");
|
|
var admin = await EnrolledClientAsync("vault-delete-admin-admin", "vdaadmin@example.com");
|
|
|
|
var team = await CreateTeamAsync(owner, "Owned");
|
|
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
|
var entry = await LookupAsync(owner, "vdaadmin@example.com");
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Admin);
|
|
|
|
await owner.PostContractAsync(
|
|
$"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation: 1));
|
|
|
|
(await DeleteAsync(admin, VaultUrl(vaultId))).StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
|
|
|
(await ReadAsync<MeResponse>(owner, MeUrl)).Vaults
|
|
.ShouldNotContain(vault => vault.VaultId == vaultId);
|
|
|
|
(await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl))
|
|
.ShouldContain(row => row.TeamId == team.TeamId, "only the owner archives a team");
|
|
|
|
var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);
|
|
|
|
(await ReadAsync<IReadOnlyList<TeamMemberSummary>>(owner, MembersUrl(team.TeamId)))
|
|
.Where(row => row.UserId == ownerMe.UserId)
|
|
.ShouldHaveSingleItem()
|
|
.Role.ShouldBe(TeamMemberRole.Owner);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A team carrying several vaults keeps its own life when one of them goes, which is the same line the
|
|
/// rename draws — and the other vault has to survive, which is the assertion that a deletion aimed at
|
|
/// one did not take its neighbour.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DeletingOneOfSeveralVaults_LeavesTheTeamAndTheOtherVaultAlone()
|
|
{
|
|
var owner = await EnrolledClientAsync("vault-delete-shared-owner");
|
|
|
|
var team = await CreateTeamAsync(owner, "Platform Engineering");
|
|
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
|
var kept = await CreateVaultAsync(owner, team.TeamId);
|
|
|
|
(await DeleteAsync(owner, VaultUrl(vaultId))).StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
|
|
|
var me = await ReadAsync<MeResponse>(owner, MeUrl);
|
|
|
|
me.Vaults.ShouldNotContain(vault => vault.VaultId == vaultId);
|
|
me.Vaults.ShouldContain(vault => vault.VaultId == kept);
|
|
|
|
(await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl))
|
|
.ShouldContain(row => row.TeamId == team.TeamId);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The one vault nothing can delete. It is created by enrollment rather than by anybody choosing to
|
|
/// make it, everything filed nowhere else lives in it, and there is no call that would make another —
|
|
/// so deleting one would leave an enrolled account with a key and nowhere to put anything.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DeletingThePersonalVault_IsRefused()
|
|
{
|
|
var owner = await EnrolledClientAsync("vault-delete-personal");
|
|
|
|
var me = await ReadAsync<MeResponse>(owner, MeUrl);
|
|
var personal = me.Vaults.Single(vault => vault.IsPersonal);
|
|
|
|
var response = await DeleteAsync(owner, VaultUrl(personal.VaultId));
|
|
|
|
await ShouldBeProblemAsync(
|
|
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant);
|
|
|
|
(await ReadAsync<MeResponse>(owner, MeUrl)).Vaults
|
|
.ShouldContain(vault => vault.VaultId == personal.VaultId);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Admin, like the rename, and for a stronger version of the same reason: this takes the vault from
|
|
/// everybody in it at once. A member is refused with 403 rather than 404 because the vault is visible
|
|
/// to them, so naming the reason leaks nothing — and an outsider still gets 404, which
|
|
/// <c>IVaultAccessService</c> requires.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task APlainMember_CannotDeleteAVaultTheyCanWriteTo()
|
|
{
|
|
var owner = await EnrolledClientAsync("vault-delete-limits-owner", "vdlowner@example.com");
|
|
var member = await EnrolledClientAsync("vault-delete-limits-member", "vdlmember@example.com");
|
|
var outsider = await EnrolledClientAsync("vault-delete-limits-outsider");
|
|
|
|
var team = await CreateTeamAsync(owner, "Limits");
|
|
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
|
var entry = await LookupAsync(owner, "vdlmember@example.com");
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
|
|
|
await ShouldBeProblemAsync(
|
|
await DeleteAsync(member, VaultUrl(vaultId)),
|
|
HttpStatusCode.Forbidden,
|
|
ProblemCodes.Forbidden);
|
|
|
|
(await DeleteAsync(outsider, VaultUrl(vaultId))).StatusCode
|
|
.ShouldBe(HttpStatusCode.NotFound);
|
|
|
|
(await ReadAsync<MeResponse>(owner, MeUrl)).Vaults
|
|
.ShouldContain(vault => vault.VaultId == vaultId);
|
|
}
|
|
|
|
/// <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 report an absence to somebody who is standing right there.
|
|
/// </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 it is the one refusal on this path that is not about the request — a code
|
|
/// shared with a rejected role would have the caller checking what they typed.
|
|
/// </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));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <b>The refusal is the end of the road, and this is what pins that.</b> A team was once joinable
|
|
/// by an address the server had never seen: an invitation row waited, and the next account to sign
|
|
/// in with that address became a member on the strength of its token's email claim. Adding somebody
|
|
/// who is not here is now refused outright, and — the half that would be easy to lose — signing in
|
|
/// afterwards joins nothing. Without the second act this test would pass against a server that had
|
|
/// merely renamed the deferred path.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AnAddressRefusedForHavingNoAccount_JoinsNothingWhenItLaterSignsIn()
|
|
{
|
|
var owner = await EnrolledClientAsync("no-deferred-owner", "ndowner@example.com");
|
|
|
|
var team = await CreateTeamAsync(owner, "No deferred joins");
|
|
var address = NewAddress();
|
|
|
|
var refused = await owner.PostContractAsync(
|
|
MembersUrl(team.TeamId),
|
|
new AddTeamMemberRequest(Guid.Empty, TeamMemberRole.Member, address));
|
|
|
|
await ShouldBeProblemAsync(refused, HttpStatusCode.NotFound, ProblemCodes.NoSuchAccount);
|
|
|
|
var (arrival, userId) = await SignInAsync(address);
|
|
|
|
(await ReadAsync<IReadOnlyList<TeamSummary>>(arrival, TeamsUrl))
|
|
.ShouldNotContain(row => row.TeamId == team.TeamId);
|
|
|
|
(await ReadAsync<IReadOnlyList<TeamMemberSummary>>(owner, MembersUrl(team.TeamId)))
|
|
.ShouldNotContain(row => row.UserId == userId);
|
|
}
|
|
|
|
// ---- 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);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The rotation itself: the generation advances, the caller holds the new key, and the flag a
|
|
/// removal set is cleared because the rotation it recorded has happened. What the server cannot do
|
|
/// is any part of the cryptography — the wrap arrives sealed and is stored as bytes.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RekeyingAVault_AdvancesTheGenerationAndWrapsItToTheCaller()
|
|
{
|
|
var owner = await EnrolledClientAsync("rotate-owner", "rotowner@example.com");
|
|
await EnrolledClientAsync("rotate-member", "rotmember@example.com");
|
|
|
|
var team = await CreateTeamAsync(owner, "Rotations");
|
|
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
|
|
|
var entry = await LookupAsync(owner, "rotmember@example.com");
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
|
await DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId));
|
|
|
|
var rotated = await RekeyAsync(owner, vaultId, generation: 2);
|
|
|
|
rotated.KeyGeneration.ShouldBe(2u);
|
|
rotated.RekeyRequired.ShouldBeFalse();
|
|
|
|
var grants = await ReadAsync<VaultGrantsResponse>(owner, $"/api/v1/vaults/{vaultId}/grants");
|
|
|
|
grants.KeyGeneration.ShouldBe(2u);
|
|
grants.RekeyRequired.ShouldBeFalse();
|
|
|
|
var me = await ReadAsync<MeResponse>(owner, MeUrl);
|
|
var summary = me.Vaults.Single(vault => vault.VaultId == vaultId);
|
|
|
|
summary.KeyGeneration.ShouldBe(2u);
|
|
summary.WrappedVaultKey.ShouldNotBeNull();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The reason a rotation does not have to re-encrypt anything to be safe, and the reason it cannot
|
|
/// throw the old grants away: every item still carries the generation it was sealed under, so the
|
|
/// caller has to go on holding every key they were given or the vault's history becomes unreadable
|
|
/// to the people who are still in the team.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARotatedVault_StillServesTheCallerTheGenerationsItHasMovedPast()
|
|
{
|
|
var owner = await EnrolledClientAsync("history-owner", "histowner@example.com");
|
|
|
|
var team = await CreateTeamAsync(owner, "History");
|
|
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
|
|
|
await RekeyAsync(owner, vaultId, generation: 2);
|
|
await RekeyAsync(owner, vaultId, generation: 3);
|
|
|
|
var me = await ReadAsync<MeResponse>(owner, MeUrl);
|
|
var summary = me.Vaults.Single(vault => vault.VaultId == vaultId);
|
|
|
|
summary.KeyGeneration.ShouldBe(3u);
|
|
|
|
summary.PriorKeyWraps.ShouldNotBeNull();
|
|
summary.PriorKeyWraps.Select(wrap => wrap.KeyGeneration).ShouldBe([1u, 2u]);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The sharing list answers "who can open this", so a member appears once however many generations
|
|
/// they hold — and the generation on their row is the best key they have, which is what makes a row
|
|
/// below the vault's own generation mean "still owed the new key".
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheGrantListing_ShowsAMemberOnceWithTheBestKeyTheyHold()
|
|
{
|
|
var owner = await EnrolledClientAsync("listing-owner", "listowner@example.com");
|
|
await EnrolledClientAsync("listing-member", "listmember@example.com");
|
|
|
|
var team = await CreateTeamAsync(owner, "Listings");
|
|
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
|
|
|
var entry = await LookupAsync(owner, "listmember@example.com");
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
|
|
|
var issued = await owner.PostContractAsync(
|
|
$"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation: 1));
|
|
|
|
issued.EnsureSuccessStatusCode();
|
|
|
|
await RekeyAsync(owner, vaultId, generation: 2);
|
|
|
|
var grants = await ReadAsync<VaultGrantsResponse>(owner, $"/api/v1/vaults/{vaultId}/grants");
|
|
|
|
grants.KeyGeneration.ShouldBe(2u);
|
|
grants.Grants.Count.ShouldBe(2);
|
|
|
|
// The rotating owner holds both generations and is listed at the newer one.
|
|
grants.Grants.Single(g => g.RecipientUserId != entry.UserId).KeyGeneration.ShouldBe(2u);
|
|
|
|
// The member has not been re-wrapped, so their row says so by generation rather than by state.
|
|
var stale = grants.Grants.Single(g => g.RecipientUserId == entry.UserId);
|
|
|
|
stale.KeyGeneration.ShouldBe(1u);
|
|
stale.State.ShouldBe(VaultGrantState.Active);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Two admins rotating at once must not both succeed, or one of them ends up holding a key nobody
|
|
/// else has and every item they write is unreadable to the rest of the team. The generation is what
|
|
/// makes that decidable: the second request is no longer one past the current, and is refused with a
|
|
/// message that says to read the vault again.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARekeyFromASupersededGeneration_IsRefused()
|
|
{
|
|
var owner = await EnrolledClientAsync("race-owner", "raceowner@example.com");
|
|
|
|
var team = await CreateTeamAsync(owner, "Races");
|
|
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
|
|
|
await RekeyAsync(owner, vaultId, generation: 2);
|
|
|
|
var stale = await owner.PostContractAsync(
|
|
$"/api/v1/vaults/{vaultId}/rekey", RekeyRequest(generation: 2));
|
|
|
|
await ShouldBeProblemAsync(stale, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A member with no key to the current generation cannot rotate. They could not have wrapped the
|
|
/// new key from the old one, so the request is either a mistake or a way to strand everybody else
|
|
/// behind a key nobody holds.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARekeyByAMemberWhoHoldsNoKey_IsRefused()
|
|
{
|
|
var owner = await EnrolledClientAsync("keyless-owner", "klowner@example.com");
|
|
var member = await EnrolledClientAsync("keyless-member", "klmember@example.com");
|
|
|
|
var team = await CreateTeamAsync(owner, "Keyless");
|
|
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
|
|
|
var entry = await LookupAsync(owner, "klmember@example.com");
|
|
|
|
// Admin, so permission is not what stops them: what stops them is holding no key.
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Admin);
|
|
|
|
var response = await member.PostContractAsync(
|
|
$"/api/v1/vaults/{vaultId}/rekey", RekeyRequest(generation: 2));
|
|
|
|
await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Sharing a rotated vault means handing over its history too, so a grant for a generation the vault
|
|
/// has moved past is accepted. One for a generation it has not reached is not: nothing is sealed
|
|
/// under it, and accepting it would let a client move the vault forward outside the one transaction
|
|
/// that is allowed to.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AGrantForAnEarlierGeneration_IsAcceptedAndOneForALaterOneIsNot()
|
|
{
|
|
var owner = await EnrolledClientAsync("gen-owner", "genowner@example.com");
|
|
var member = await EnrolledClientAsync("gen-member", "genmember@example.com");
|
|
|
|
var team = await CreateTeamAsync(owner, "Generations");
|
|
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
|
|
|
var entry = await LookupAsync(owner, "genmember@example.com");
|
|
|
|
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
|
await RekeyAsync(owner, vaultId, generation: 2);
|
|
|
|
foreach (var generation in (uint[])[1, 2])
|
|
{
|
|
var accepted = await owner.PostContractAsync(
|
|
$"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation));
|
|
|
|
accepted.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
|
}
|
|
|
|
var ahead = await owner.PostContractAsync(
|
|
$"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation: 3));
|
|
|
|
await ShouldBeProblemAsync(ahead, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant);
|
|
|
|
// Both grants are live at once, which is what lets the recipient read the vault's history and
|
|
// its present. A single row per recipient would have made one of them overwrite the other.
|
|
var me = await ReadAsync<MeResponse>(member, MeUrl);
|
|
|
|
var summary = me.Vaults.Single(vault => vault.VaultId == vaultId);
|
|
|
|
summary.KeyGeneration.ShouldBe(2u);
|
|
summary.PriorKeyWraps.ShouldNotBeNull().ShouldHaveSingleItem().KeyGeneration.ShouldBe(1u);
|
|
}
|
|
|
|
// ---- 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 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() => $"newcomer-{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, without enrolling it.</summary>
|
|
/// <remarks>
|
|
/// No enrollment, because the account this stands for is somebody between their first sign-in and
|
|
/// setting a machine up — the one the directory cannot answer for and who can still be added by
|
|
/// address. Driving <c>/me</c> is what runs just-in-time provisioning, which is what creates the
|
|
/// account at all.
|
|
/// </remarks>
|
|
private async Task<(HttpClient Client, Guid UserId)> SignInAsync(string email)
|
|
{
|
|
var client = fixture.CreateClientFor($"newcomer-{Guid.CreateVersion7():N}", email);
|
|
|
|
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;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The wrap is the right shape and nothing more, for the reason <see cref="CreateVaultAsync"/> gives:
|
|
/// the server stores it opaquely, so a real seal here would be exercising the crypto library.
|
|
/// </remarks>
|
|
private static RekeyVaultRequest RekeyRequest(uint generation) =>
|
|
new(
|
|
KeyGeneration: generation,
|
|
WrappedVaultKey: new byte[110],
|
|
GrantSignature: new byte[64],
|
|
GrantedAt: DateTimeOffset.UnixEpoch);
|
|
|
|
private static IssueVaultGrantRequest GrantRequest(DirectoryEntry entry, uint generation) =>
|
|
new(
|
|
entry.UserId,
|
|
entry.Fingerprint,
|
|
KeyGeneration: generation,
|
|
WrappedVaultKey: new byte[110],
|
|
KeyLogHead: new byte[32],
|
|
GrantSignature: new byte[64],
|
|
GrantedAt: DateTimeOffset.UnixEpoch);
|
|
|
|
private static async Task<VaultSummary> RekeyAsync(
|
|
HttpClient client,
|
|
Guid vaultId,
|
|
uint generation)
|
|
{
|
|
var response = await client.PostContractAsync(
|
|
$"/api/v1/vaults/{vaultId}/rekey", RekeyRequest(generation));
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
return (await response.Content.ReadContractAsync<VaultSummary>())!;
|
|
}
|
|
|
|
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 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);
|
|
}
|
|
}
|