Files
DodoSSH/tests/DodoSSH.Api.Tests/TeamEndpointTests.cs
T
jaap-jan 69bc9e270b Let a team be joined only by somebody who is already here
An invitation decided access from an assertion about an address. Everything else
in this model decides it from something a person did — an admin naming an
account, a key holder wrapping a vault key to a key they verified — and this was
the one place a token's email claim was the thing that let somebody in.

It was guarded as tightly as that can be guarded: the claim was refused outright
on an unverified or absent `email_verified`, with no setting to relax it. But the
guard and the risk were the same shape. The whole defence was one boolean sent by
a system the deployment does not control.

So `POST /teams/{id}/members` is the only way in, and an address with no account
is refused with `no-such-account` — which is now the end of the road rather than
the signal to invite. Both clients say the remedy: that person signs in here
once, which is what creates the account, and then they can be added. The desktop
leaves the address in the box, because a message telling you to come back later
is one you act on later.

Gone with it: the `team_invitation` table, the claim hook in the sign-in path,
and `Oidc:EmailVerifiedClaim`, which that hook was the only reader of. Nothing in
the server now reads the email claim to decide anything.

Pending invitations are dropped rather than converted. Converting one would mean
creating a membership because an address matched, which is the property being
removed — and an invitation to an address that did have an account here had
already been claimed by the hourly sweep, so what is left is offers to people who
never arrived.

Two tests carry the property rather than the feature: the endpoint inventory
asserts the three routes are absent, and the API suite adds an address that has
no account, watches the refusal, then signs that address in and checks it joined
nothing. Without the second half, a server that merely renamed the deferred path
would pass.
2026-08-05 08:28:57 +02:00

1486 lines
64 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>
/// 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);
}
}