Merge branch 'claude/adding-hosts-phone-docs-e472b0'
ci / build and test (push) Successful in 1m16s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 45s

This commit is contained in:
2026-08-03 14:41:18 +02:00
43 changed files with 6976 additions and 147 deletions
+8 -2
View File
@@ -81,12 +81,18 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
}
/// <summary>Creates a client carrying a valid token for the given subject.</summary>
public HttpClient CreateClientFor(string subject, string? email = null)
/// <remarks>
/// <paramref name="emailVerified"/> defaults to true, which is what a provider asserts about an
/// address it has checked and what every ordinary sign-in means. Passing false mints a token that
/// carries the address and no <c>email_verified</c> claim — the shape a team invitation has to
/// refuse, and the only way a test can present it.
/// </remarks>
public HttpClient CreateClientFor(string subject, string? email = null, bool emailVerified = true)
{
var client = CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
IdentityProvider.MintToken(subject, email));
IdentityProvider.MintToken(subject, email, emailVerified: emailVerified));
return client;
}
+15
View File
@@ -33,6 +33,21 @@ internal static class ContractJson
return client.PostAsJsonAsync(url, value, Options, TestContext.Current.CancellationToken);
}
/// <remarks>
/// The same options as <see cref="PostContractAsync"/>, and here for the same reason rather than
/// for symmetry: a PUT that serialised its enums differently from a POST would let a wire form the
/// specification never described reach exactly the endpoints nothing else covers.
/// </remarks>
internal static Task<HttpResponseMessage> PutContractAsync<T>(
this HttpClient client,
string url,
T value)
{
ArgumentNullException.ThrowIfNull(client);
return client.PutAsJsonAsync(url, value, Options, TestContext.Current.CancellationToken);
}
internal static Task<T?> ReadContractAsync<T>(this HttpContent content)
{
ArgumentNullException.ThrowIfNull(content);
@@ -74,6 +74,23 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
"PUT /api/v1/teams/{teamId:guid}/members/{userId:guid}/role name=ChangeTeamMemberRole tags=Teams policies=Authenticated anon=False",
"DELETE /api/v1/teams/{teamId:guid}/members/{userId:guid} name=RemoveTeamMember tags=Teams policies=Authenticated anon=False",
// Administering a team you are already in, and none of it moves key material — so Authenticated
// for the same reason the membership routes above are. Two of the three are gated harder inside
// the handler than this table can show: archiving and handing the team over check for the owner
// rather than for an admin, because an admin the owner promoted must not be able to take the
// team from them. See TeamAccess.IsOwner.
"PUT /api/v1/teams/{teamId:guid} name=UpdateTeam tags=Teams policies=Authenticated anon=False",
"DELETE /api/v1/teams/{teamId:guid} name=ArchiveTeam tags=Teams policies=Authenticated anon=False",
"POST /api/v1/teams/{teamId:guid}/owner name=TransferTeamOwnership tags=Teams policies=Authenticated anon=False",
// Authenticated, and pointedly not Enrolled. An invitation names an address that may have no
// account at all and certainly holds no key; gating these on Enrolled would be demanding a key
// of the one participant the feature exists for. Membership is not readability — somebody still
// has to wrap the vault key afterwards — so no key is involved on either side.
"GET /api/v1/teams/{teamId:guid}/invitations name=ListTeamInvitations tags=Teams policies=Authenticated anon=False",
"POST /api/v1/teams/{teamId:guid}/invitations name=CreateTeamInvitation tags=Teams policies=Authenticated anon=False",
"DELETE /api/v1/teams/{teamId:guid}/invitations/{invitationId:guid} name=RevokeTeamInvitation tags=Teams policies=Authenticated anon=False",
// Enrolled, because both end in a vault key being wrapped: creating a team means creating a vault
// in it, and neither is reachable without a key of one's own.
"POST /api/v1/teams name=CreateTeam tags=Teams policies=Enrolled anon=False",
@@ -67,13 +67,22 @@ public sealed class StubIdentityProvider : IDisposable
/// <param name="audience">Override the audience, to test rejection.</param>
/// <param name="issuer">Override the issuer, to test rejection.</param>
/// <param name="expires">Override expiry, to test rejection.</param>
/// <param name="emailVerified">
/// Whether the token asserts <c>email_verified</c> over <paramref name="email"/>. True by
/// default, because that is what a provider says about an address it has checked and every
/// existing caller means a genuine sign-in. False omits the claim outright rather than sending
/// <c>false</c>: an absent claim is what a provider that was never configured to send one
/// produces, and it is the case the server must not read as verified. The claim is emitted only
/// alongside an address, since on its own it asserts nothing about anybody.
/// </param>
public string MintToken(
string subject,
string? email = null,
string? name = null,
string? audience = null,
string? issuer = null,
DateTime? expires = null)
DateTime? expires = null,
bool emailVerified = true)
{
var now = TimeProvider.System.GetUtcNow().UtcDateTime;
@@ -85,6 +94,17 @@ public sealed class StubIdentityProvider : IDisposable
if (email is not null)
{
claims.Add(new System.Security.Claims.Claim("email", email));
if (emailVerified)
{
// Boolean, so the handler writes a JSON boolean rather than a quoted string. A real
// provider sends one, and the server parses rather than compares — so a test that
// sent a string would agree with an implementation that only handled strings.
claims.Add(new System.Security.Claims.Claim(
"email_verified",
"true",
System.Security.Claims.ClaimValueTypes.Boolean));
}
}
if (name is not null)
+753 -50
View File
@@ -5,7 +5,7 @@ using DodoSSH.Contracts;
namespace DodoSSH.Api.Tests;
/// <summary>
/// Teams, membership and the vault key grants that make a team vault readable.
/// Teams, membership, invitations and the vault key grants that make a team vault readable.
/// </summary>
/// <remarks>
/// <para>
@@ -21,13 +21,23 @@ namespace DodoSSH.Api.Tests;
/// for a key its recipient no longer holds. Each of those looks exactly like working software from the
/// outside.
/// </para>
/// <para>
/// Two boundaries in here are load-bearing beyond their own endpoint, and both are of that invisible
/// kind. An admin who can archive a team or hand it away is an admin who can take it from the person
/// who promoted them, and nothing about the response would say so. An invitation claimed on an address
/// the identity provider never vouched for is a way into somebody else's team, and every other part of
/// that request succeeds. Neither has a symptom; each has a test.
/// </para>
/// </remarks>
[Collection(ApiCollection.Name)]
public sealed class TeamEndpointTests(ApiFixture fixture)
{
private const string TeamsUrl = "/api/v1/teams";
private const string MeUrl = "/api/v1/me";
private const string EnrollUrl = "/api/v1/me/enrollment";
// ---- Creating and listing ----
[Fact]
public async Task CreatingATeam_MakesTheCallerItsOwner()
{
@@ -79,11 +89,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
var response = await client.PostContractAsync(
TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "Second", slug, null));
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
var problem = await response.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.TeamSlugTaken);
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.TeamSlugTaken);
}
/// <remarks>
@@ -98,18 +104,325 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
var team = await CreateTeamAsync(owner, "Private");
var real = await stranger.GetAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members", UriKind.Relative),
TestContext.Current.CancellationToken);
var invented = await stranger.GetAsync(
new Uri($"{TeamsUrl}/{Guid.CreateVersion7()}/members", UriKind.Relative),
TestContext.Current.CancellationToken);
var real = await GetAsync(stranger, MembersUrl(team.TeamId));
var invented = await GetAsync(stranger, MembersUrl(Guid.CreateVersion7()));
real.StatusCode.ShouldBe(HttpStatusCode.NotFound);
invented.StatusCode.ShouldBe(real.StatusCode);
}
// ---- Renaming ----
/// <remarks>
/// The rename is read back from the list rather than from the response body, because the list is
/// what a member's screen is built from and it is assembled by different code. The slug is asserted
/// unchanged because it is unique only among live teams: a rename that moved it could take a slug an
/// archived team still holds, and that archived team could then never be brought back.
/// </remarks>
[Fact]
public async Task RenamingATeam_ChangesTheListedNameAndLeavesTheSlugAlone()
{
var owner = await EnrolledClientAsync("rename-owner");
var team = await CreateTeamAsync(owner, "Before");
var response = await owner.PutContractAsync(
TeamUrl(team.TeamId), new UpdateTeamRequest("After", "A description it did not have."));
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
var renamed = listed.Where(row => row.TeamId == team.TeamId).ShouldHaveSingleItem();
renamed.Name.ShouldBe("After");
renamed.Description.ShouldBe("A description it did not have.");
renamed.Slug.ShouldBe(team.Slug);
}
/// <remarks>
/// Null clears the description rather than leaving it alone. A PUT that treated an absent value as
/// "no change" would make a cleared description impossible to express at all, since there is no
/// other verb for it.
/// </remarks>
[Fact]
public async Task RenamingWithoutADescription_ClearsTheOneThatWasThere()
{
var owner = await EnrolledClientAsync("rename-clear-owner");
var team = await PostAsync<CreateTeamRequest, TeamSummary>(
owner,
TeamsUrl,
new CreateTeamRequest(
Guid.CreateVersion7(), "Described", $"team-{Guid.CreateVersion7():N}", "Something."));
team.Description.ShouldBe("Something.");
var response = await owner.PutContractAsync(
TeamUrl(team.TeamId), new UpdateTeamRequest("Described", null));
response.EnsureSuccessStatusCode();
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
listed.Where(row => row.TeamId == team.TeamId)
.ShouldHaveSingleItem()
.Description.ShouldBeNull();
}
/// <remarks>
/// A member is refused with 403 rather than 404: the team is visible to them, so naming the reason
/// leaks nothing and "you are not an admin" is a more useful answer than "no such team".
/// </remarks>
[Fact]
public async Task APlainMember_CanRenameNothingAndCanNeitherArchiveNorHandOverTheTeam()
{
var owner = await EnrolledClientAsync("member-limits-owner", "mlowner@example.com");
var member = await EnrolledClientAsync("member-limits-member", "mlmember@example.com");
var team = await CreateTeamAsync(owner, "Limits");
var entry = await LookupAsync(owner, "mlmember@example.com");
var memberMe = await ReadAsync<MeResponse>(member, MeUrl);
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var renamed = await member.PutContractAsync(
TeamUrl(team.TeamId), new UpdateTeamRequest("Theirs now", null));
await ShouldBeProblemAsync(renamed, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
var archived = await DeleteAsync(member, TeamUrl(team.TeamId));
await ShouldBeProblemAsync(archived, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
var transferred = await member.PostContractAsync(
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(memberMe.UserId));
await ShouldBeProblemAsync(transferred, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
}
/// <remarks>
/// <para>
/// <b>The <c>TeamAccess.IsOwner</c> boundary, and the most important authorization test in this
/// feature.</b> An admin may manage members and vaults, and that is deliberately not the same
/// permission as deciding whether the team continues to exist or who controls it. If either of
/// these checks were widened to <c>CanAdminister</c> — which is what every neighbouring endpoint
/// uses, so it is the easy mistake — anybody the owner promoted could archive the team out from
/// under them or take it outright, and nothing in the response of any other test would change.
/// </para>
/// <para>
/// The rename is asserted in the same test on purpose: it is what shows the admin genuinely holds
/// administrative rights here, so the two refusals are the boundary rather than a broken role.
/// </para>
/// </remarks>
[Fact]
public async Task AnAdminWhoIsNotTheOwner_MayRenameButMayNotArchiveOrHandOverTheTeam()
{
var owner = await EnrolledClientAsync("admin-boundary-owner", "abowner@example.com");
var admin = await EnrolledClientAsync("admin-boundary-admin", "abadmin@example.com");
var team = await CreateTeamAsync(owner, "Boundary");
var entry = await LookupAsync(owner, "abadmin@example.com");
var adminMe = await ReadAsync<MeResponse>(admin, MeUrl);
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Admin);
// They really are an admin: this one succeeds.
var renamed = await admin.PutContractAsync(
TeamUrl(team.TeamId), new UpdateTeamRequest("Renamed by an admin", null));
renamed.StatusCode.ShouldBe(HttpStatusCode.OK);
var archived = await DeleteAsync(admin, TeamUrl(team.TeamId));
await ShouldBeProblemAsync(archived, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
var transferred = await admin.PostContractAsync(
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(adminMe.UserId));
await ShouldBeProblemAsync(transferred, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
// And nothing moved: the team is still there and still owned by the person who made it.
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
owner, MembersUrl(team.TeamId));
var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);
members.Where(row => row.UserId == ownerMe.UserId)
.ShouldHaveSingleItem()
.Role.ShouldBe(TeamMemberRole.Owner);
}
// ---- Archiving ----
/// <remarks>
/// Archiving hides a team from every member's list at once, and a team vault resolves through
/// membership — so archiving one that still owned vaults would take those vaults away from
/// everybody holding a key, including the caller, with no way back because nothing in this product
/// deletes a vault. The refusal is the end of that road rather than a step on it, which is why the
/// team is asserted still listed afterwards.
/// </remarks>
[Fact]
public async Task ArchivingATeamThatOwnsAVault_IsRefusedAndLeavesTheTeamListed()
{
var owner = await EnrolledClientAsync("archive-vault-owner");
var team = await CreateTeamAsync(owner, "Occupied");
await CreateVaultAsync(owner, team.TeamId);
var response = await DeleteAsync(owner, TeamUrl(team.TeamId));
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.TeamNotEmpty);
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
listed.ShouldContain(row => row.TeamId == team.TeamId);
}
/// <remarks>
/// Every member's list, not just the caller's. Memberships are archived with the team in the same
/// transaction, and a live membership pointing at an archived team would leave the other member
/// still seeing it — which is the shape a half-applied archive takes.
/// </remarks>
[Fact]
public async Task ArchivingAnEmptyTeam_RemovesItFromEveryMembersList()
{
var owner = await EnrolledClientAsync("archive-owner", "aowner@example.com");
var member = await EnrolledClientAsync("archive-member", "amember@example.com");
var team = await CreateTeamAsync(owner, "Wound up");
var entry = await LookupAsync(owner, "amember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
(await ReadAsync<IReadOnlyList<TeamSummary>>(member, TeamsUrl))
.ShouldContain(row => row.TeamId == team.TeamId);
var response = await DeleteAsync(owner, TeamUrl(team.TeamId));
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
(await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl))
.ShouldNotContain(row => row.TeamId == team.TeamId);
(await ReadAsync<IReadOnlyList<TeamSummary>>(member, TeamsUrl))
.ShouldNotContain(row => row.TeamId == team.TeamId);
// And it is gone the way a team nobody is in is gone, rather than merely unlisted.
(await GetAsync(owner, MembersUrl(team.TeamId))).StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
// ---- Ownership ----
/// <remarks>
/// <b>Both roles, because either alone would pass while the team was broken.</b> Asserting only
/// that the recipient is now owner would pass with the team owned twice; asserting only that the
/// outgoing owner is an admin would pass with it owned by nobody. Ownership is sole and the two
/// writes are one transaction precisely so that neither of those states can exist, so the count of
/// owners is asserted too.
/// </remarks>
[Fact]
public async Task TransferringOwnership_MakesTheTargetOwnerAndTheOutgoingOwnerAnAdmin()
{
var owner = await EnrolledClientAsync("transfer-owner", "towner@example.com");
var successor = await EnrolledClientAsync("transfer-successor", "tsuccessor@example.com");
var team = await CreateTeamAsync(owner, "Handover");
var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);
var entry = await LookupAsync(owner, "tsuccessor@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var response = await owner.PostContractAsync(
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
owner, MembersUrl(team.TeamId));
members.Where(row => row.UserId == entry.UserId)
.ShouldHaveSingleItem()
.Role.ShouldBe(TeamMemberRole.Owner);
members.Where(row => row.UserId == ownerMe.UserId)
.ShouldHaveSingleItem()
.Role.ShouldBe(TeamMemberRole.Admin, "the outgoing owner is demoted, not removed");
members.Count(row => row.Role == TeamMemberRole.Owner).ShouldBe(1);
// And the recipient is told so by the endpoint their own screen reads.
(await ReadAsync<IReadOnlyList<TeamSummary>>(successor, TeamsUrl))
.Where(row => row.TeamId == team.TeamId)
.ShouldHaveSingleItem()
.Role.ShouldBe(TeamMemberRole.Owner);
}
/// <remarks>
/// The thing that was impossible before this endpoint existed. An owner could not be removed and
/// could not be demoted, so somebody who left the company owning a team left it owned by them for
/// ever — a state only an operator with database access could fix.
/// </remarks>
[Fact]
public async Task AfterATransfer_TheFormerOwnerCanFinallyBeRemoved()
{
var owner = await EnrolledClientAsync("departing-owner", "downer@example.com");
var successor = await EnrolledClientAsync("departing-successor", "dsuccessor@example.com");
var team = await CreateTeamAsync(owner, "Departure");
var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);
var entry = await LookupAsync(owner, "dsuccessor@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var transferred = await owner.PostContractAsync(
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));
transferred.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var removed = await DeleteAsync(successor, MemberUrl(team.TeamId, ownerMe.UserId));
removed.StatusCode.ShouldBe(HttpStatusCode.NoContent);
(await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl))
.ShouldNotContain(row => row.TeamId == team.TeamId);
}
/// <remarks>
/// Adding somebody and handing them the team in one step would let an id supplied once take it, so
/// the recipient has to be an active member already. This is the same refusal that stops a stranger
/// being made owner by pasting their id.
/// </remarks>
[Fact]
public async Task TransferringToSomebodyWhoIsNotAMember_IsRefused()
{
var owner = await EnrolledClientAsync("transfer-closed-owner", "tcowner@example.com");
await EnrolledClientAsync("transfer-outsider", "toutsider@example.com");
var team = await CreateTeamAsync(owner, "Closed handover");
var entry = await LookupAsync(owner, "toutsider@example.com");
var response = await owner.PostContractAsync(
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));
await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeam);
}
[Fact]
public async Task TransferringToYourself_IsRefused()
{
var owner = await EnrolledClientAsync("transfer-self-owner");
var team = await CreateTeamAsync(owner, "Already mine");
var me = await ReadAsync<MeResponse>(owner, MeUrl);
var response = await owner.PostContractAsync(
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(me.UserId));
await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeam);
}
// ---- Membership ----
/// <remarks>
/// The membership half of M3 in one test: a team vault appears in the other member's <c>/me</c> the
/// moment they are added, and it appears <em>without</em> a wrapped key. That null is the whole
@@ -128,7 +441,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var me = await ReadAsync<MeResponse>(member, "/api/v1/me");
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");
@@ -163,11 +476,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
var push = await viewer.PostContractAsync(
$"/api/v1/vaults/{vaultId}/sync/push", new SyncPushRequest([]));
push.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
var problem = await push.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.Forbidden);
await ShouldBeProblemAsync(push, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
}
/// <remarks>
@@ -187,16 +496,14 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var before = await ReadAsync<MeResponse>(member, "/api/v1/me");
var before = await ReadAsync<MeResponse>(member, MeUrl);
before.Vaults.ShouldContain(summary => summary.VaultId == vaultId);
var removed = await owner.DeleteAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members/{entry.UserId}", UriKind.Relative),
TestContext.Current.CancellationToken);
var removed = await DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId));
removed.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var after = await ReadAsync<MeResponse>(member, "/api/v1/me");
var after = await ReadAsync<MeResponse>(member, MeUrl);
after.Vaults.ShouldNotContain(summary => summary.VaultId == vaultId);
var pull = await member.PostContractAsync(
@@ -223,9 +530,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
await owner.DeleteAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members/{entry.UserId}", UriKind.Relative),
TestContext.Current.CancellationToken);
await DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId));
var grants = await ReadAsync<VaultGrantsResponse>(owner, $"/api/v1/vaults/{vaultId}/grants");
@@ -233,9 +538,11 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
}
/// <remarks>
/// The owner cannot be removed or demoted, because nothing can appoint a replacement yet. 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.
/// 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()
@@ -243,19 +550,328 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
var owner = await EnrolledClientAsync("sole-owner", "sole@example.com");
var team = await CreateTeamAsync(owner, "Sole");
var me = await ReadAsync<MeResponse>(owner, "/api/v1/me");
var me = await ReadAsync<MeResponse>(owner, MeUrl);
var response = await owner.DeleteAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members/{me.UserId}", UriKind.Relative),
TestContext.Current.CancellationToken);
var response = await DeleteAsync(owner, MemberUrl(team.TeamId, me.UserId));
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
var problem = await response.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.LastTeamOwner);
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.LastTeamOwner);
}
/// <remarks>
/// The other half of the same guarantee, and the one a transfer endpoint could plausibly have
/// loosened. Demoting the owner through the role endpoint cannot appoint a replacement in the same
/// breath, so it would leave the team ownerless — which is exactly the state
/// <c>TransferOwnershipAsync</c> does two writes in one transaction to avoid.
/// </remarks>
[Fact]
public async Task TheOwner_CannotBeDemotedOnTheirOwn()
{
var owner = await EnrolledClientAsync("demote-owner", "demote@example.com");
var team = await CreateTeamAsync(owner, "Undemotable");
var me = await ReadAsync<MeResponse>(owner, MeUrl);
var response = await owner.PutContractAsync(
MemberRoleUrl(team.TeamId, me.UserId),
new ChangeTeamMemberRoleRequest(TeamMemberRole.Admin));
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.LastTeamOwner);
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
owner, MembersUrl(team.TeamId));
members.Where(row => row.UserId == me.UserId)
.ShouldHaveSingleItem()
.Role.ShouldBe(TeamMemberRole.Owner);
}
/// <remarks>
/// A real value, not a placeholder. <c>LastActiveAt</c> is the field the teams screen uses to say
/// whether a colleague has been here at all, and the failure it guards against is the one that made
/// the column impossible to offer before: a null for somebody who has plainly been making requests
/// reads as "never", which is a lie about a person.
/// </remarks>
[Fact]
public async Task AMemberListing_ReportsWhenAnAccountWasLastActive()
{
var owner = await EnrolledClientAsync("last-seen-owner");
var team = await CreateTeamAsync(owner, "Last seen");
var me = await ReadAsync<MeResponse>(owner, MeUrl);
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
owner, MembersUrl(team.TeamId));
var self = members.Where(row => row.UserId == me.UserId).ShouldHaveSingleItem();
self.LastActiveAt.ShouldNotBeNull(
"this account has made several authenticated requests already");
// Within the window the server writes on, which is the whole of the precision this carries.
self.LastActiveAt.Value.ShouldBeGreaterThan(TimeProvider.System.GetUtcNow().AddHours(-1));
}
// ---- Invitations ----
/// <remarks>
/// The expiry is asserted rather than merely present. An invitation that never lapsed would be a
/// standing offer against an address, and company addresses are handed to the next person to hold
/// the job — so the person who inherits the mailbox would inherit the team.
/// </remarks>
[Fact]
public async Task InvitingAnAddress_ListsItAsPendingWithItsRoleAndAnExpiry()
{
var owner = await EnrolledClientAsync("invite-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Invitations");
var created = await InviteAsync(owner, team.TeamId, address, TeamMemberRole.Admin);
var listed = await FindInvitationAsync(owner, team.TeamId, created.InvitationId);
listed.Email.ShouldBe(address);
listed.Role.ShouldBe(TeamMemberRole.Admin);
listed.State.ShouldBe(TeamInvitationState.Pending);
listed.AcceptedAt.ShouldBeNull();
(listed.ExpiresAt - listed.CreatedAt).ShouldBe(TimeSpan.FromDays(14));
}
/// <remarks>
/// The same body twice, as a client whose response was lost would send it — the shape team and
/// vault creation already have. Two invitations to one address would show the same person twice on
/// the teams screen and take two revocations to withdraw.
/// </remarks>
[Fact]
public async Task RepeatingAnInvitation_ReturnsTheSameOneRatherThanASecond()
{
var owner = await EnrolledClientAsync("invite-retry-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Retried invitations");
var request = new CreateTeamInvitationRequest(
Guid.CreateVersion7(), address, TeamMemberRole.Member);
var first = await PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
owner, InvitationsUrl(team.TeamId), request);
var second = await PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
owner, InvitationsUrl(team.TeamId), request);
second.InvitationId.ShouldBe(first.InvitationId);
var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
owner, InvitationsUrl(team.TeamId));
// Case-insensitively, because the column is citext and two addresses differing only in case
// are one address — a second row under a different casing would still be a second invitation.
listed.Count(row => string.Equals(row.Email, address, StringComparison.OrdinalIgnoreCase))
.ShouldBe(1);
}
[Fact]
public async Task ASecondInvitationToTheSameAddress_IsRefused()
{
var owner = await EnrolledClientAsync("invite-duplicate-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Duplicate invitations");
await InviteAsync(owner, team.TeamId, address);
// A different id, so this is a second invitation rather than a retry of the first.
var response = await owner.PostContractAsync(
InvitationsUrl(team.TeamId),
new CreateTeamInvitationRequest(Guid.CreateVersion7(), address, TeamMemberRole.Admin));
await ShouldBeProblemAsync(
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
}
/// <remarks>
/// Ownership is sole and is handed over deliberately. An invitation that conferred it would let an
/// address typed once take the team the moment somebody signed in with it — and the invitee is by
/// definition somebody nobody here has met.
/// </remarks>
[Fact]
public async Task InvitingSomebodyAsOwner_IsRefused()
{
var owner = await EnrolledClientAsync("invite-owner-role-owner");
var team = await CreateTeamAsync(owner, "Not for sale");
var response = await owner.PostContractAsync(
InvitationsUrl(team.TeamId),
new CreateTeamInvitationRequest(
Guid.CreateVersion7(), NewAddress(), TeamMemberRole.Owner));
await ShouldBeProblemAsync(
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
}
[Fact]
public async Task AMalformedAddress_IsRefused()
{
var owner = await EnrolledClientAsync("invite-malformed-owner");
var team = await CreateTeamAsync(owner, "Shapes");
var response = await owner.PostContractAsync(
InvitationsUrl(team.TeamId),
new CreateTeamInvitationRequest(
Guid.CreateVersion7(), "not an address", TeamMemberRole.Member));
await ShouldBeProblemAsync(
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
}
/// <remarks>
/// A withdrawn invitation stays in the listing rather than vanishing, so the screen can show that it
/// was withdrawn rather than letting it read as never sent. Withdrawing it twice is 404, for the
/// reason revoking a device grant gives: a caller driving towards "that invitation will not let
/// anybody in" can treat 404 as having arrived.
/// </remarks>
[Fact]
public async Task RevokingAnInvitation_MarksItRevokedAndCannotBeDoneTwice()
{
var owner = await EnrolledClientAsync("invite-revoke-owner");
var team = await CreateTeamAsync(owner, "Withdrawals");
var invitation = await InviteAsync(owner, team.TeamId, NewAddress());
var revoked = await DeleteAsync(
owner, InvitationUrl(team.TeamId, invitation.InvitationId));
revoked.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);
listed.State.ShouldBe(TeamInvitationState.Revoked);
var again = await DeleteAsync(owner, InvitationUrl(team.TeamId, invitation.InvitationId));
again.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
/// <remarks>
/// An invitation is a fact about the team, so any member may read the list — whoever is about to be
/// handed a vault key needs to see who else is on their way in — but only an admin may write one.
/// </remarks>
[Fact]
public async Task APlainMember_MayReadInvitationsAndMayNotIssueThem()
{
var owner = await EnrolledClientAsync("invite-reader-owner", "irowner@example.com");
var member = await EnrolledClientAsync("invite-reader-member", "irmember@example.com");
var team = await CreateTeamAsync(owner, "Readable invitations");
var entry = await LookupAsync(owner, "irmember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var invitation = await InviteAsync(owner, team.TeamId, NewAddress());
var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
member, InvitationsUrl(team.TeamId));
listed.ShouldContain(row => row.InvitationId == invitation.InvitationId);
var refused = await member.PostContractAsync(
InvitationsUrl(team.TeamId),
new CreateTeamInvitationRequest(
Guid.CreateVersion7(), NewAddress(), TeamMemberRole.Member));
await ShouldBeProblemAsync(refused, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
}
// ---- Claiming an invitation at sign-in ----
/// <remarks>
/// The heart of the feature, and the only path by which an invitation becomes anything. There is no
/// token and no mail: the row says "the next account to sign in with this address joins this team",
/// and just-in-time provisioning is what reads it. What it creates is a membership and not a key —
/// the vault key still has to be wrapped to them from a machine that holds one.
/// </remarks>
[Fact]
public async Task SigningInWithAnInvitedAddress_JoinsTheTeamAndMarksTheInvitationAccepted()
{
var owner = await EnrolledClientAsync("claim-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Claimed");
var invitation = await InviteAsync(owner, team.TeamId, address, TeamMemberRole.Member);
var (invitee, inviteeUserId) = await SignInAsync(address);
(await ReadAsync<IReadOnlyList<TeamSummary>>(invitee, TeamsUrl))
.Where(row => row.TeamId == team.TeamId)
.ShouldHaveSingleItem()
.Role.ShouldBe(TeamMemberRole.Member);
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
owner, MembersUrl(team.TeamId));
var joined = members.Where(row => row.UserId == inviteeUserId).ShouldHaveSingleItem();
joined.Status.ShouldBe(TeamMemberStatus.Active);
joined.Role.ShouldBe(TeamMemberRole.Member);
joined.IsEnrolled.ShouldBeFalse("membership is authorization, and they hold no key yet");
var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);
listed.State.ShouldBe(TeamInvitationState.Accepted);
listed.AcceptedAt.ShouldNotBeNull();
}
/// <remarks>
/// <b>The security test this feature stands on.</b> An invitation is authorization: claiming one is
/// what decides that this server will serve somebody a team's vaults. An address the identity
/// provider has not vouched for is an address anybody able to obtain a token can name, so an
/// unverified one must confer nothing — that is the same attack <c>AllowEmailLinking</c> exists to
/// refuse, arriving by a different door. The failure would be silent by construction: the account is
/// provisioned either way and every other part of the request succeeds, so nothing but this would
/// notice that the wrong person had walked into the team.
/// </remarks>
[Fact]
public async Task AnAddressTheProviderHasNotVerified_ClaimsNothing()
{
var owner = await EnrolledClientAsync("unverified-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Verified only");
var invitation = await InviteAsync(owner, team.TeamId, address);
var (impostor, _) = await SignInAsync(address, emailVerified: false);
(await ReadAsync<IReadOnlyList<TeamSummary>>(impostor, TeamsUrl)).ShouldBeEmpty();
var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);
listed.State.ShouldBe(TeamInvitationState.Pending);
listed.AcceptedAt.ShouldBeNull();
}
/// <remarks>
/// A withdrawn invitation is withdrawn, which the claim path has to honour independently — it reads
/// the invitation table directly rather than going back through the endpoint that refused.
/// </remarks>
[Fact]
public async Task SigningInAfterAnInvitationWasWithdrawn_JoinsNothing()
{
var owner = await EnrolledClientAsync("withdrawn-owner");
var address = NewAddress();
var team = await CreateTeamAsync(owner, "Withdrawn");
var invitation = await InviteAsync(owner, team.TeamId, address);
await DeleteAsync(owner, InvitationUrl(team.TeamId, invitation.InvitationId));
var (invitee, _) = await SignInAsync(address);
(await ReadAsync<IReadOnlyList<TeamSummary>>(invitee, TeamsUrl)).ShouldBeEmpty();
}
// ---- Vault key grants ----
/// <remarks>
/// A grant to somebody outside the team is refused. It would be a row that looks like sharing and
/// does nothing, because the access check will go on refusing them the vault — and a sharing screen
@@ -283,11 +899,8 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
GrantSignature: new byte[64],
GrantedAt: DateTimeOffset.UnixEpoch));
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.InvalidVaultGrant);
await ShouldBeProblemAsync(
response, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant);
}
/// <remarks>
@@ -323,6 +936,8 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
// ---- The directory and the key log ----
/// <remarks>
/// The directory has no search. Asserting it rather than trusting the implementation, because a
/// prefix match added later for convenience turns a server that stores addresses in plaintext into a
@@ -394,6 +1009,29 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
}
}
// ---- Helpers ----
private static string TeamUrl(Guid teamId) => $"{TeamsUrl}/{teamId}";
private static string OwnerUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/owner";
private static string MembersUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/members";
private static string MemberUrl(Guid teamId, Guid userId) => $"{MembersUrl(teamId)}/{userId}";
private static string MemberRoleUrl(Guid teamId, Guid userId) =>
$"{MemberUrl(teamId, userId)}/role";
private static string InvitationsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/invitations";
private static string InvitationUrl(Guid teamId, Guid invitationId) =>
$"{InvitationsUrl(teamId)}/{invitationId}";
private static string TeamVaultsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/vaults";
/// <summary>An address no account holds, uniquified because the container is shared.</summary>
private static string NewAddress() => $"invitee-{Guid.CreateVersion7():N}@example.com";
private async Task<HttpClient> EnrolledClientAsync(string subject, string? email = null)
{
var unique = $"{subject}-{Guid.CreateVersion7():N}";
@@ -416,6 +1054,24 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
return client;
}
/// <summary>Signs a brand-new account in, which is what claims any invitation to its address.</summary>
/// <remarks>
/// No enrollment, because an invitee has no key and the claim path must not need one — requiring it
/// would be requiring it of exactly the person who cannot yet supply it. Driving <c>/me</c> is what
/// runs just-in-time provisioning, and provisioning is where the claim happens.
/// </remarks>
private async Task<(HttpClient Client, Guid UserId)> SignInAsync(
string email,
bool emailVerified = true)
{
var client = fixture.CreateClientFor(
$"invitee-{Guid.CreateVersion7():N}", email, emailVerified);
var me = await ReadAsync<MeResponse>(client, MeUrl);
return (client, me.UserId);
}
/// <summary>Uniquified addresses, keyed on the readable one a test wrote.</summary>
private readonly Dictionary<string, string> addresses = new(StringComparer.OrdinalIgnoreCase);
@@ -425,7 +1081,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
/// 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 Task<TeamSummary> CreateTeamAsync(HttpClient client, string name) =>
private static Task<TeamSummary> CreateTeamAsync(HttpClient client, string name) =>
PostAsync<CreateTeamRequest, TeamSummary>(
client,
TeamsUrl,
@@ -437,11 +1093,11 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
/// 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 async Task<Guid> CreateVaultAsync(HttpClient client, Guid teamId)
private static async Task<Guid> CreateVaultAsync(HttpClient client, Guid teamId)
{
var vault = await PostAsync<CreateTeamVaultRequest, VaultSummary>(
client,
$"{TeamsUrl}/{teamId}/vaults",
TeamVaultsUrl(teamId),
new CreateTeamVaultRequest(
Guid.CreateVersion7(),
"Team vault",
@@ -469,11 +1125,37 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
TeamMemberRole role)
{
var response = await client.PostContractAsync(
$"{TeamsUrl}/{teamId}/members", new AddTeamMemberRequest(userId, role));
MembersUrl(teamId), new AddTeamMemberRequest(userId, role));
response.EnsureSuccessStatusCode();
}
private static Task<TeamInvitationSummary> InviteAsync(
HttpClient client,
Guid teamId,
string email,
TeamMemberRole role = TeamMemberRole.Member) =>
PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
client,
InvitationsUrl(teamId),
new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, role));
/// <remarks>
/// Filtered by id rather than taken from a position in the list, because the container is shared and
/// every other class's invitations are in the same table. A count over the whole listing would pass
/// or fail depending on what else ran.
/// </remarks>
private static async Task<TeamInvitationSummary> FindInvitationAsync(
HttpClient client,
Guid teamId,
Guid invitationId)
{
var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
client, InvitationsUrl(teamId));
return listed.Where(row => row.InvitationId == invitationId).ShouldHaveSingleItem();
}
private static async Task<TResponse> PostAsync<TRequest, TResponse>(
HttpClient client,
string url,
@@ -488,11 +1170,32 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
private static async Task<T> ReadAsync<T>(HttpClient client, string url)
{
var response = await client.GetAsync(
new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken);
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);
}
}
@@ -1403,6 +1403,88 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
failure)));
/// <summary>Lays the vault screen out at the width it gets once the nav rail has taken its column.</summary>
/// <remarks>
/// <para>
/// The teams screen had no entry in this suite at all until it grew four sections — a rename form, an
/// armed confirmation, an invitations list and a key-holders list — plus a second line in the member
/// row. Its right-hand column is the narrowest measured here: the window's minimum is 1016, the nav
/// rail takes 190 and the team list 268, leaving 558 for everything above.
/// </para>
/// <para>
/// Every list is seeded, and seeded with the long rows rather than the convenient ones — see
/// <see cref="StubTeamServer"/>. The two states that hide half the screen, the rename form and the
/// confirmation, are measured in their own tests below rather than here, because a control that is
/// collapsed when the window is laid out is a control this suite has not checked.
/// </para>
/// </remarks>
[Fact]
public Task TheTeamsScreen_FitsWithEveryListPopulated() =>
OnTheTeamsScreenAsync(
teams => { },
window => LayoutHarness.Unreachable(window)
.ShouldBeEmpty("the teams screen with members, invitations and key holders"));
/// <remarks>
/// The rename form is drawn in place, above the members list, and pushes everything below it down.
/// </remarks>
[Fact]
public Task TheTeamsScreen_FitsWhileRenamingATeam() =>
OnTheTeamsScreenAsync(
teams => teams.RenameTeamCommand.Execute(null),
window => LayoutHarness.Unreachable(window)
.ShouldBeEmpty("the teams screen with the rename form open"));
/// <remarks>
/// The armed confirmation carries two sentences of prose and replaces the header's buttons. It is the
/// tallest thing that can appear above the members list, so it is the case most likely to push the
/// key-holders list off the bottom.
/// </remarks>
[Fact]
public Task TheTeamsScreen_FitsWhileConfirmingAnArchive() =>
OnTheTeamsScreenAsync(
teams => teams.ArchiveTeamCommand.Execute(null),
window => LayoutHarness.Unreachable(window)
.ShouldBeEmpty("the teams screen with the archive confirmation armed"));
/// <remarks>
/// A real <c>TeamsViewModel</c> over a stub server rather than the unlocked vault the rest of this
/// suite uses, because nothing on this screen is vault content: it is read from the server on open.
/// The session function answers null, which is the state a member is in before anybody has wrapped
/// them a key — and it is also the one that draws the most text, since every vault row then carries
/// the "waiting for a key" sentence.
/// </remarks>
private static async Task OnTheTeamsScreenAsync(
Action<TeamsViewModel> arrange,
Action<Window> assert)
{
using var teamServer = new StubTeamServer();
var teams = new TeamsViewModel(() => teamServer, () => null);
await teams.LoadAsync(Token);
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
arrange(teams);
var screen = new TeamsScreen { DataContext = teams };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
assert(window);
}
finally
{
window.Close();
}
},
Token);
}
private Task MeasureVaultAsync(Action<IReadOnlyList<string>> assert) =>
OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
@@ -0,0 +1,235 @@
using DodoSSH.Client.Api;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Session;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// The least server a <c>TeamsViewModel</c> needs in order to be laid out with something in it.
/// </summary>
/// <remarks>
/// <para>
/// The teams screen is the one screen in this suite whose content cannot come from an unlocked vault,
/// because none of it is vault content: a team, its members, its invitations and who holds a key to a
/// vault are all read from the server on open, and the suite's <c>FakeAccountServer</c> implements
/// <see cref="IAccountApi"/> and nothing else. Rather than teach that fake five more interfaces for one
/// screen, this serves fixed rows and refuses everything a layout test has no business calling.
/// </para>
/// <para>
/// The rows are deliberately the <em>long</em> ones. A layout suite that measured "Bob" in a column sized
/// for an email address would certify a shape no real team produces — so the names, addresses and status
/// sentences here are at or near the length the interface can really be handed, which is what makes an
/// overflow show up at the minimum window rather than on somebody's screen.
/// </para>
/// </remarks>
internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
{
private static readonly Guid OwnerId = Guid.CreateVersion7();
private static readonly Guid ColleagueId = Guid.CreateVersion7();
private static readonly Guid TeamId = Guid.CreateVersion7();
private static readonly Guid VaultId = Guid.CreateVersion7();
/// <inheritdoc />
public Uri ServerUrl { get; } = new("https://dodossh.example");
/// <inheritdoc />
public ITeamApi Teams => this;
/// <inheritdoc />
public IVaultGrantApi Grants => this;
/// <inheritdoc />
public IAccountApi Account => throw new NotSupportedException();
/// <inheritdoc />
public ISyncApi Sync => throw new NotSupportedException();
/// <inheritdoc />
public IDirectoryApi Directory => throw new NotSupportedException();
/// <inheritdoc />
public IKeyBindingAuthorizer KeyBinding => throw new NotSupportedException();
/// <inheritdoc />
public SyncOptions SyncOptions => new();
/// <inheritdoc />
public string? RefreshToken => null;
/// <summary>The vault whose key holders are listed, so a test can select it.</summary>
internal static Guid TeamVaultId => VaultId;
/// <inheritdoc />
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamSummary>>(
[
new TeamSummary(
TeamId,
"Platform Engineering",
"platform-engineering",
"Everything that runs the estate.",
TeamMemberRole.Owner,
MemberCount: 2,
VaultCount: 1,
DateTimeOffset.UnixEpoch),
]);
/// <inheritdoc />
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamMemberSummary>>(
[
new TeamMemberSummary(
OwnerId,
"alexandra.hollingsworth@dodotech.example",
"Alexandra Hollingsworth",
TeamMemberRole.Owner,
TeamMemberStatus.Active,
IsEnrolled: true,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch),
// The unenrolled case on purpose: its key-state phrase is the longest the column ever
// carries, and it is the row that decides whether that column is wide enough.
new TeamMemberSummary(
ColleagueId,
"bartholomew.fotheringay@dodotech.example",
"Bartholomew Fotheringay",
TeamMemberRole.Member,
TeamMemberStatus.Active,
IsEnrolled: false,
DateTimeOffset.UnixEpoch,
LastActiveAt: null),
]);
/// <inheritdoc />
public Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamInvitationSummary>>(
[
// Pending, because its sentence is the long one — it has to carry the whole mechanism,
// since nothing was sent and there is nothing else on the screen that could say so.
new TeamInvitationSummary(
Guid.CreateVersion7(),
"wilhelmina.ashworth-blake@dodotech.example",
TeamMemberRole.Admin,
TeamInvitationState.Pending,
OwnerId,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch.AddDays(14),
AcceptedAt: null),
]);
/// <inheritdoc />
public Task<VaultGrantsResponse> ListVaultGrantsAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
Task.FromResult(new VaultGrantsResponse(
vaultId,
KeyGeneration: 2,
RekeyRequired: true,
Grants:
[
new VaultGrantSummary(
OwnerId,
"alexandra.hollingsworth@dodotech.example",
"Alexandra Hollingsworth",
KeyGeneration: 2,
VaultGrantState.Active,
OwnerId,
DateTimeOffset.UnixEpoch,
RevokedAt: null),
// A generation behind, so the "stale" phrasing is the one being measured rather than
// the two-word happy case.
new VaultGrantSummary(
ColleagueId,
"bartholomew.fotheringay@dodotech.example",
"Bartholomew Fotheringay",
KeyGeneration: 1,
VaultGrantState.Active,
OwnerId,
DateTimeOffset.UnixEpoch,
RevokedAt: null),
]));
/// <inheritdoc />
public Task<TeamSummary> CreateTeamAsync(
CreateTeamRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<TeamSummary> UpdateTeamAsync(
Guid teamId,
UpdateTeamRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken) =>
throw new NotSupportedException();
/// <inheritdoc />
public Task TransferTeamOwnershipAsync(
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<TeamMemberSummary> AddTeamMemberAsync(
Guid teamId,
AddTeamMemberRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
Guid teamId,
Guid userId,
ChangeTeamMemberRoleRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> RemoveTeamMemberAsync(
Guid teamId,
Guid userId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId,
CreateTeamVaultRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task IssueVaultGrantAsync(
Guid vaultId,
IssueVaultGrantRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<bool> RevokeVaultGrantAsync(
Guid vaultId,
Guid userId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public void Dispose()
{
// Nothing held.
}
}
@@ -29,6 +29,7 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
private readonly Dictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> grants = [];
private readonly List<KeyLogRecord> keyLog = [];
private readonly List<DirectoryEntry> directory = [];
private readonly Dictionary<Guid, List<TeamInvitationSummary>> invitations = [];
/// <inheritdoc />
public ITeamApi Teams => this;
@@ -107,12 +108,110 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
TeamMemberRole.Owner,
TeamMemberStatus.Active,
IsEnrolled: true,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch),
];
return Task.FromResult(team);
}
/// <inheritdoc />
public Task<TeamSummary> UpdateTeamAsync(
Guid teamId,
UpdateTeamRequest request,
CancellationToken cancellationToken)
{
var index = teams.FindIndex(team => team.TeamId == teamId);
if (index < 0)
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.NotFound, ProblemCodes.InvalidTeam, "No such team.");
}
// The slug is deliberately not touched, matching the server: a rename changes the display
// name only. A fake that also moved the slug would let a test assert behaviour nothing has.
teams[index] = teams[index] with
{
Name = request.Name,
Description = request.Description,
};
return Task.FromResult(teams[index]);
}
/// <inheritdoc />
/// <remarks>
/// The vault refusal is reproduced rather than skipped, unlike the other server rules here. It is
/// the one whose consequence the shell has to render — a status line explaining why nothing
/// happened — so a fake that always succeeded would leave that path untested.
/// </remarks>
public Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken)
{
var index = teams.FindIndex(team => team.TeamId == teamId);
if (index < 0)
{
return Task.FromResult(false);
}
if (teamVaults.Values.Any(vault => vault.TeamId == teamId))
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.Conflict,
ProblemCodes.TeamNotEmpty,
"This team still owns vaults, and archiving it would take them away from everybody "
+ "holding a key — including you.");
}
teams.RemoveAt(index);
members.Remove(teamId);
invitations.Remove(teamId);
return Task.FromResult(true);
}
/// <inheritdoc />
/// <remarks>
/// Both rows move, because a fake that only promoted the recipient would let a test pass while
/// the team was owned twice — which is the exact failure the real service uses a transaction to
/// make impossible.
/// </remarks>
public Task TransferTeamOwnershipAsync(
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken)
{
var list = members.GetValueOrDefault(teamId, []);
var incoming = list.FindIndex(member => member.UserId == request.UserId);
if (incoming < 0)
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.BadRequest,
ProblemCodes.InvalidTeam,
"That account is not an active member of this team.");
}
var outgoing = list.FindIndex(member => member.Role == TeamMemberRole.Owner);
list[incoming] = list[incoming] with { Role = TeamMemberRole.Owner };
if (outgoing >= 0)
{
list[outgoing] = list[outgoing] with { Role = TeamMemberRole.Admin };
}
var index = teams.FindIndex(team => team.TeamId == teamId);
if (index >= 0)
{
teams[index] = teams[index] with { Role = TeamMemberRole.Admin };
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
@@ -132,6 +231,8 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
ProblemCodes.InvalidTeam,
"No such account on this server.");
// LastActiveAt is left null: this account has been added, not seen. The owner's row carries a
// real one, so both branches of the interface's "last active / never" split are exercised.
var member = new TeamMemberSummary(
entry.UserId,
entry.Email,
@@ -139,7 +240,8 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
request.Role,
TeamMemberStatus.Active,
IsEnrolled: true,
DateTimeOffset.UnixEpoch);
DateTimeOffset.UnixEpoch,
LastActiveAt: null);
members[teamId] = [.. members.GetValueOrDefault(teamId, []), member];
@@ -148,6 +250,69 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
return Task.FromResult(member);
}
/// <inheritdoc />
public Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamInvitationSummary>>(
invitations.TryGetValue(teamId, out var list) ? [.. list] : []);
/// <inheritdoc />
public Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken)
{
var list = invitations.GetValueOrDefault(teamId, []);
if (list.Exists(invitation =>
invitation.State == TeamInvitationState.Pending
&& string.Equals(invitation.Email, request.Email, StringComparison.OrdinalIgnoreCase)))
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.BadRequest,
ProblemCodes.InvalidTeamInvitation,
"There is already an invitation to that address for this team.");
}
var invited = new TeamInvitationSummary(
request.InvitationId,
request.Email,
request.Role,
TeamInvitationState.Pending,
UserId,
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch.AddDays(14),
AcceptedAt: null);
invitations[teamId] = [.. list, invited];
return Task.FromResult(invited);
}
/// <inheritdoc />
public Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken)
{
var list = invitations.GetValueOrDefault(teamId, []);
var index = list.FindIndex(invitation =>
invitation.InvitationId == invitationId
&& invitation.State == TeamInvitationState.Pending);
if (index < 0)
{
return Task.FromResult(false);
}
// Kept and marked rather than removed, as the server keeps it: the screen has to be able to
// say an invitation was withdrawn rather than letting it vanish and read as never sent.
list[index] = list[index] with { State = TeamInvitationState.Revoked };
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
Guid teamId,
@@ -6,6 +6,7 @@ using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests;
@@ -273,6 +274,259 @@ public sealed class TeamSharingTests : IAsyncLifetime
.ShouldBe("eu-west-1", "a tag that is not in the list is a tag nothing can reach");
}
/// <remarks>
/// The screen's answer to "who can actually open this", which until now it could not give at all —
/// the endpoint existed and nothing called it. Asserted after a share rather than before, because
/// an empty list proves nothing about whether the call was made.
/// </remarks>
[Fact]
public async Task SelectingATeamVault_ListsWhoHoldsAKeyToIt()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.SelectedVault = teams.Vaults[0];
await teams.ShareVaultCommand.ExecuteAsync(null);
// Selecting the vault again is what drives the read; the share above happened after the
// previous selection had already loaded an empty list.
teams.SelectedVault = null;
teams.SelectedVault = teams.Vaults[0];
var holder = teams.Grants.ShouldHaveSingleItem();
holder.UserId.ShouldBe(colleague);
holder.IsLive.ShouldBeTrue(teams.Status);
holder.State.ShouldBe("holds a key");
}
/// <remarks>
/// A role change is authorization only. The status line has to say so, because the obvious reading
/// of "demoted to viewer" is that they can no longer read the vault — and they still can, with the
/// key they were already wrapped. Withdrawing that is a separate act.
/// </remarks>
[Fact]
public async Task ChangingAMembersRole_SaysItDoesNotTakeBackTheKeyTheyHold()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
await teams.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Admin);
teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("ADMIN");
teams.Status.ShouldContain("does not withdraw a vault key");
}
/// <remarks>
/// The owner's role is the one that cannot be changed this way, and the interface has to refuse it
/// itself rather than letting the server do it: a button that produced a server error would be
/// reporting a rule the screen already knew.
/// </remarks>
[Fact]
public async Task MakingSomebodyOwnerThroughTheRolePicker_IsRefusedAndPointsAtHandingOver()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
await teams.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Owner);
teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("MEMBER");
teams.Status.ShouldContain("HAND OVER");
}
/// <remarks>
/// <para>
/// Both halves, because a transfer that only promoted the recipient would leave the team owned
/// twice and a test asserting one role would pass anyway. That is the exact failure the server uses
/// a single transaction to make impossible, so the client test asserts the same pair.
/// </para>
/// <para>
/// It also goes through the armed confirmation rather than calling the command directly, since
/// arming and confirming are where the target id is carried — and carrying it on the selection
/// instead is how a confirmation ends up applied to whatever was clicked last.
/// </para>
/// </remarks>
[Fact]
public async Task HandingOverATeam_MakesThemTheOwnerAndTheCallerAnAdmin()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.TransferOwnershipCommand.Execute(null);
teams.IsConfirming.ShouldBeTrue("the hand-over has to be answered, not just pressed");
teams.ShowsTeamActions.ShouldBeFalse("the buttons that armed it are replaced, not left live");
await teams.ConfirmActionCommand.ExecuteAsync(null);
teams.Members.Single(member => member.UserId == colleague).Role.ShouldBe("OWNER");
teams.Members.Single(member => member.IsSelf).Role.ShouldBe("ADMIN");
teams.IsConfirming.ShouldBeFalse();
}
/// <remarks>
/// Archiving is refused while the team owns a vault, and the refusal has to reach the screen. The
/// failure this guards is the quiet one: a client that swallowed the 409 and reloaded would show a
/// team that is still there with no explanation of why nothing happened.
/// </remarks>
[Fact]
public async Task ArchivingATeamThatOwnsAVault_IsRefusedAndSaysWhy()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
teams.ArchiveTeamCommand.Execute(null);
await teams.ConfirmActionCommand.ExecuteAsync(null);
teams.Teams.ShouldContain(team => team.Slug == "platform");
teams.Status.ShouldContain("holding a key");
}
/// <remarks>
/// An empty team can go, and this is the only operation on the screen that removes something from
/// everybody's list at once.
/// </remarks>
[Fact]
public async Task ArchivingAnEmptyTeam_RemovesIt()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.ArchiveTeamCommand.Execute(null);
await teams.ConfirmActionCommand.ExecuteAsync(null);
teams.Teams.ShouldNotContain(team => team.Slug == "platform");
teams.Status.ShouldContain("Archived");
}
/// <remarks>
/// Renaming leaves the slug alone, and the status line says so unprompted — somebody who assumed
/// otherwise would find out from a URL much later, which is the worst moment to find out.
/// </remarks>
[Fact]
public async Task RenamingATeam_LeavesItsSlugAlone()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.RenameTeamCommand.Execute(null);
teams.EditTeamName = "Platform Engineering";
await teams.SaveTeamCommand.ExecuteAsync(null);
var team = teams.Teams.ShouldHaveSingleItem();
team.Name.ShouldBe("Platform Engineering");
team.Slug.ShouldBe("platform");
teams.Status.ShouldContain("slug is still 'platform'");
}
/// <remarks>
/// <para>
/// The address the directory does not know used to be a dead end — the screen said they had to sign
/// in first and stopped. It invites them instead, from the same button, because which of the two
/// applies is a fact about the server's account table rather than about what the user is doing.
/// </para>
/// <para>
/// The status assertion is the point of the test. Nothing is sent, and an interface that said
/// "invited" without saying that would leave somebody waiting for an email that is never coming.
/// </para>
/// </remarks>
[Fact]
public async Task AddingAnAddressWithNoAccount_InvitesItAndSaysNothingWasSent()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "newcomer@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
var invitation = teams.Invitations.ShouldHaveSingleItem();
invitation.Email.ShouldBe("newcomer@example.com");
invitation.IsPending.ShouldBeTrue();
invitation.State.ShouldContain("Nothing was sent");
teams.Status.ShouldContain("cannot send mail");
}
/// <remarks>
/// A withdrawn invitation stays on the list saying it was withdrawn, rather than vanishing. One that
/// disappeared would read as never having been sent, which is the same thing the screen looks like
/// before anybody does anything.
/// </remarks>
[Fact]
public async Task WithdrawingAnInvitation_LeavesItListedAsWithdrawn()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
teams.InviteEmail = "newcomer@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedInvitation = teams.Invitations.ShouldHaveSingleItem();
await teams.RevokeInvitationCommand.ExecuteAsync(null);
teams.Invitations.ShouldHaveSingleItem().State.ShouldBe("withdrawn");
teams.Status.ShouldContain("Withdrew the invitation");
}
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
{
await teams.LoadAsync(Token);
@@ -468,6 +468,108 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
UpdatedAtUtc = Now,
};
// ---- Team invitations ----
/// <remarks>
/// One live invitation per address per team. Without the index two admins acting a minute apart
/// would each leave a row, and the claim at sign-in would apply both — quietly overwriting whichever
/// role was decided second with whichever was written first.
/// </remarks>
[Fact]
public async Task Invitation_IsUniquePerTeamAndAddress()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
var team = SeedTeam(context, user.Id);
var email = $"invite{Guid.CreateVersion7():N}@example.com";
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
await context.SaveChangesAsync();
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
exception.InnerException.ShouldBeOfType<PostgresException>()
.SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
}
/// <remarks>
/// The citext proof, and it is load-bearing rather than tidy: the address in an invitation is typed
/// by a person and the one on the token is chosen by the identity provider, so a column that
/// compared them case-sensitively would let <c>Alice@</c> and <c>alice@</c> be two invitations and
/// would make the claim at sign-in miss the one that was actually sent.
/// </remarks>
[Fact]
public async Task Invitation_IsCaseInsensitivelyUniquePerTeam()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
var team = SeedTeam(context, user.Id);
var email = $"Invite{Guid.CreateVersion7():N}@Example.com";
context.TeamInvitations.Add(NewInvitation(team.Id, email.ToUpperInvariant(), user.Id));
await context.SaveChangesAsync();
context.TeamInvitations.Add(NewInvitation(team.Id, email.ToLowerInvariant(), user.Id));
var exception = await Should.ThrowAsync<DbUpdateException>(() => context.SaveChangesAsync());
exception.InnerException.ShouldBeOfType<PostgresException>()
.SqlState.ShouldBe(PostgresErrorCodes.UniqueViolation);
}
/// <remarks>
/// The other half of the filter. Withdrawing an invitation and issuing a fresh one — at a different
/// role, say — has to be possible, so the uniqueness is among live rows rather than all of them, and
/// the withdrawn row stays for the history.
/// </remarks>
[Fact]
public async Task Invitation_MayBeReissuedAfterItIsRevoked()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
var team = SeedTeam(context, user.Id);
var email = $"invite{Guid.CreateVersion7():N}@example.com";
var first = NewInvitation(team.Id, email, user.Id);
context.TeamInvitations.Add(first);
await context.SaveChangesAsync();
first.RevokedAtUtc = Now;
await context.SaveChangesAsync();
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
await Should.NotThrowAsync(() => context.SaveChangesAsync());
}
/// <remarks>
/// An accepted invitation frees the slot too, which is what lets somebody removed from a team be
/// invited back. The claim marks the old row accepted rather than deleting it, so without this the
/// second invitation would collide with a row that has already done its job.
/// </remarks>
[Fact]
public async Task Invitation_MayBeReissuedAfterItIsAccepted()
{
await using var context = fixture.CreateContext();
var user = await SeedUserAsync(context);
var team = SeedTeam(context, user.Id);
var email = $"invite{Guid.CreateVersion7():N}@example.com";
var first = NewInvitation(team.Id, email, user.Id);
context.TeamInvitations.Add(first);
await context.SaveChangesAsync();
first.AcceptedAtUtc = Now;
first.AcceptedByUserId = user.Id;
await context.SaveChangesAsync();
context.TeamInvitations.Add(NewInvitation(team.Id, email, user.Id));
await Should.NotThrowAsync(() => context.SaveChangesAsync());
}
private static async Task<UserAccount> SeedUserAsync(DodoDbContext context)
{
var user = NewUser("https://idp.example", Guid.CreateVersion7().ToString());
@@ -568,4 +670,15 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
ActorUserId = Guid.CreateVersion7(),
OccurredAtUtc = Now,
};
private static TeamInvitation NewInvitation(Guid teamId, string email, Guid invitedBy) => new()
{
Id = Guid.CreateVersion7(),
TeamId = teamId,
Email = email,
Role = TeamRole.Member,
InvitedByUserId = invitedBy,
CreatedAtUtc = Now,
ExpiresAtUtc = Now.AddDays(14),
};
}