Public Access
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.
This commit is contained in:
@@ -84,17 +84,18 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
|
||||
/// <summary>Creates a client carrying a valid token for the given subject.</summary>
|
||||
/// <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.
|
||||
/// There is no verified-address knob here, and its absence is worth a sentence. It used to exist so
|
||||
/// a test could present the one shape the server refused — an address the provider had not vouched
|
||||
/// for, offered against a pending team invitation. Nothing in the server reads
|
||||
/// <c>email_verified</c> now, because nothing decides access from an address at all, so a parameter
|
||||
/// here would be one that changes no outcome.
|
||||
/// </remarks>
|
||||
public HttpClient CreateClientFor(string subject, string? email = null, bool emailVerified = true)
|
||||
public HttpClient CreateClientFor(string subject, string? email = null)
|
||||
{
|
||||
var client = CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
|
||||
"Bearer",
|
||||
IdentityProvider.MintToken(subject, email, emailVerified: emailVerified));
|
||||
IdentityProvider.MintToken(subject, email));
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -89,13 +89,10 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
|
||||
"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",
|
||||
// There are deliberately no invitation routes here, and their absence is the assertion. A team
|
||||
// was once joinable by an address the server had never seen, claimed at sign-in from the token's
|
||||
// email claim; membership is now only ever granted to an account somebody named. If three
|
||||
// /invitations entries reappear in this list, that property has been given back.
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -67,22 +67,19 @@ 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>
|
||||
/// <remarks>
|
||||
/// <c>email_verified</c> travels beside an address because a real provider sends it, and for no
|
||||
/// other reason: the server reads it nowhere. It did once — to decide whether a pending team
|
||||
/// invitation addressed to that address could be claimed — and there are no invitations. Keeping it
|
||||
/// in the token keeps this stub honest about the shape of a real one.
|
||||
/// </remarks>
|
||||
public string MintToken(
|
||||
string subject,
|
||||
string? email = null,
|
||||
string? name = null,
|
||||
string? audience = null,
|
||||
string? issuer = null,
|
||||
DateTime? expires = null,
|
||||
bool emailVerified = true)
|
||||
DateTime? expires = null)
|
||||
{
|
||||
var now = TimeProvider.System.GetUtcNow().UtcDateTime;
|
||||
|
||||
@@ -95,16 +92,12 @@ public sealed class StubIdentityProvider : IDisposable
|
||||
{
|
||||
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));
|
||||
}
|
||||
// Boolean, so the handler writes a JSON boolean rather than a quoted string, which is what
|
||||
// a real provider sends.
|
||||
claims.Add(new System.Security.Claims.Claim(
|
||||
"email_verified",
|
||||
"true",
|
||||
System.Security.Claims.ClaimValueTypes.Boolean));
|
||||
}
|
||||
|
||||
if (name is not null)
|
||||
|
||||
@@ -5,7 +5,7 @@ using DodoSSH.Contracts;
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Teams, membership, invitations and the vault key grants that make a team vault readable.
|
||||
/// Teams, membership and the vault key grants that make a team vault readable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -24,9 +24,9 @@ namespace DodoSSH.Api.Tests;
|
||||
/// <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.
|
||||
/// 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)]
|
||||
@@ -709,7 +709,7 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
/// An account exists from its owner's first authenticated request and publishes no key until they
|
||||
/// enroll, and the directory omits it for that whole window — deliberately, because an entry exists
|
||||
/// to be wrapped to. So the lookup is asserted empty first: that is not a missing account, and a
|
||||
/// caller that read it as one would invite an address that already has one.
|
||||
/// 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>
|
||||
@@ -749,8 +749,8 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
|
||||
/// <remarks>
|
||||
/// The other side of it. An address with no account is refused under its own code rather than the
|
||||
/// general one, because the caller can act on it unprompted — there is nobody to add, so invite
|
||||
/// them — and a code shared with a rejected role would leave them guessing which had happened.
|
||||
/// 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()
|
||||
@@ -928,263 +928,35 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
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.
|
||||
/// <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 InvitingAnAddress_ListsItAsPendingWithItsRoleAndAnExpiry()
|
||||
public async Task AnAddressRefusedForHavingNoAccount_JoinsNothingWhenItLaterSignsIn()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("invite-owner");
|
||||
var owner = await EnrolledClientAsync("no-deferred-owner", "ndowner@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "No deferred joins");
|
||||
var address = NewAddress();
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Invitations");
|
||||
var created = await InviteAsync(owner, team.TeamId, address, TeamMemberRole.Admin);
|
||||
var refused = await owner.PostContractAsync(
|
||||
MembersUrl(team.TeamId),
|
||||
new AddTeamMemberRequest(Guid.Empty, TeamMemberRole.Member, address));
|
||||
|
||||
var listed = await FindInvitationAsync(owner, team.TeamId, created.InvitationId);
|
||||
await ShouldBeProblemAsync(refused, HttpStatusCode.NotFound, ProblemCodes.NoSuchAccount);
|
||||
|
||||
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));
|
||||
}
|
||||
var (arrival, userId) = await SignInAsync(address);
|
||||
|
||||
/// <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();
|
||||
(await ReadAsync<IReadOnlyList<TeamSummary>>(arrival, TeamsUrl))
|
||||
.ShouldNotContain(row => row.TeamId == team.TeamId);
|
||||
|
||||
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();
|
||||
(await ReadAsync<IReadOnlyList<TeamMemberSummary>>(owner, MembersUrl(team.TeamId)))
|
||||
.ShouldNotContain(row => row.UserId == userId);
|
||||
}
|
||||
|
||||
// ---- Vault key grants ----
|
||||
@@ -1531,17 +1303,12 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
private static string MemberRoleUrl(Guid teamId, Guid userId) =>
|
||||
$"{MemberUrl(teamId, userId)}/role";
|
||||
|
||||
private static string InvitationsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/invitations";
|
||||
|
||||
private static string InvitationUrl(Guid teamId, Guid invitationId) =>
|
||||
$"{InvitationsUrl(teamId)}/{invitationId}";
|
||||
|
||||
private static string TeamVaultsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/vaults";
|
||||
|
||||
private static string VaultUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}";
|
||||
|
||||
/// <summary>An address no account holds, uniquified because the container is shared.</summary>
|
||||
private static string NewAddress() => $"invitee-{Guid.CreateVersion7():N}@example.com";
|
||||
private static string NewAddress() => $"newcomer-{Guid.CreateVersion7():N}@example.com";
|
||||
|
||||
private async Task<HttpClient> EnrolledClientAsync(string subject, string? email = null)
|
||||
{
|
||||
@@ -1565,18 +1332,16 @@ 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>
|
||||
/// <summary>Signs a brand-new account in, without enrolling it.</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.
|
||||
/// 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,
|
||||
bool emailVerified = true)
|
||||
private async Task<(HttpClient Client, Guid UserId)> SignInAsync(string email)
|
||||
{
|
||||
var client = fixture.CreateClientFor(
|
||||
$"invitee-{Guid.CreateVersion7():N}", email, emailVerified);
|
||||
var client = fixture.CreateClientFor($"newcomer-{Guid.CreateVersion7():N}", email);
|
||||
|
||||
var me = await ReadAsync<MeResponse>(client, MeUrl);
|
||||
|
||||
@@ -1675,32 +1440,6 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
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,
|
||||
|
||||
@@ -1706,7 +1706,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
OnTheVaultsScreenAsync(
|
||||
vaults => { },
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the vaults screen with members, invitations and key holders"));
|
||||
.ShouldBeEmpty("the vaults screen with members and key holders"));
|
||||
|
||||
/// <remarks>
|
||||
/// The rename form is drawn in place, above the members list, and pushes everything below it down.
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace DodoSSH.Client.App.Layout.Tests;
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The vaults screen draws its list from the session and everything under it from the server: who is in a
|
||||
/// vault, who has been invited, and who holds a key are all read on open, and the suite's
|
||||
/// vault and who holds a key are both read 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.
|
||||
@@ -124,25 +124,6 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
|
||||
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,
|
||||
@@ -216,18 +197,6 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// Accepts the vault, so a layout test can put a shared one into the session it is drawing.
|
||||
/// </summary>
|
||||
|
||||
@@ -36,7 +36,6 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
||||
grants = [];
|
||||
private readonly List<KeyLogRecord> keyLog = [];
|
||||
private readonly List<DirectoryEntry> directory = [];
|
||||
private readonly Dictionary<Guid, List<TeamInvitationSummary>> invitations = [];
|
||||
|
||||
/// <summary>
|
||||
/// Every account on this fake server, enrolled or not.
|
||||
@@ -345,7 +344,6 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
||||
|
||||
teams.RemoveAt(index);
|
||||
members.Remove(teamId);
|
||||
invitations.Remove(teamId);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
@@ -464,69 +462,6 @@ 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,
|
||||
@@ -697,7 +632,6 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
||||
{
|
||||
teams.RemoveAll(team => team.TeamId == teamId);
|
||||
members.Remove(teamId);
|
||||
invitations.Remove(teamId);
|
||||
}
|
||||
|
||||
return Task.FromResult(true);
|
||||
|
||||
@@ -30,8 +30,8 @@ namespace DodoSSH.Client.App.Tests;
|
||||
/// <para>
|
||||
/// It was <c>TeamSharingTests</c>, and the screen it drives stopped being about teams: a vault is what
|
||||
/// gets made and named, and the membership list behind it is made with it. The team is still what the
|
||||
/// server authorises against, which is why the assertions about roles, hand-over and invitations are all
|
||||
/// still here — they are the same operations, reached through the vault they apply to.
|
||||
/// server authorises against, which is why the assertions about roles and hand-over are all still here —
|
||||
/// they are the same operations, reached through the vault they apply to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VaultSharingTests : IAsyncLifetime
|
||||
@@ -120,7 +120,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
var vaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Members.Count.ShouldBe(2, vaults.Status);
|
||||
@@ -148,7 +148,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
@@ -190,7 +190,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
foreach (var address in (string[])["bob@example.com", "carol@example.com"])
|
||||
{
|
||||
vaults.InviteEmail = address;
|
||||
vaults.NewMemberEmail = address;
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
var vaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
// Removing them is what rotates the vault, so the next person to be added arrives at a vault
|
||||
@@ -243,7 +243,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == first);
|
||||
await vaults.RemoveMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.InviteEmail = "carol@example.com";
|
||||
vaults.NewMemberEmail = "carol@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
server.GenerationsGranted(vaultId, second).ShouldBe([1u, 2u], vaults.Status);
|
||||
@@ -275,7 +275,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
// that corrupted the log afterwards would be asserting about the second one only.
|
||||
server.CorruptKeyLog = true;
|
||||
|
||||
vaults.InviteEmail = "mallory@example.com";
|
||||
vaults.NewMemberEmail = "mallory@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
var vaultId = vaults.SelectedVault!.VaultId;
|
||||
@@ -412,7 +412,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
var vaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
server.IssuedGrants.ShouldContainKey((vaultId, colleague));
|
||||
@@ -486,7 +486,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vaults.SelectedVault = personal;
|
||||
vaults.SelectedIsShared.ShouldBeFalse();
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Members.ShouldBeEmpty();
|
||||
@@ -1185,7 +1185,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
// Two, and the creator is the other: their own self-grant is what makes a vault they just made
|
||||
@@ -1214,7 +1214,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
@@ -1240,7 +1240,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
@@ -1273,7 +1273,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
@@ -1324,17 +1324,19 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
/// <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.
|
||||
/// <b>An address with no account is a refusal, and the sentence has to say what to do about it.</b>
|
||||
/// This used to issue an invitation from the same button — a standing instruction that the next
|
||||
/// account signing in with that address joined the vault. It does not any more: an address is not a
|
||||
/// way in, and only an account somebody named can be added.
|
||||
/// </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.
|
||||
/// The status assertion is the point of the test. "No such account" on its own is a dead end that
|
||||
/// reads as a typo, so what is pinned is that the message names the address and says the remedy —
|
||||
/// they sign in here once — and that nothing is held for them in the meantime.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AddingAnAddressWithNoAccount_InvitesItAndSaysNothingWasSent()
|
||||
public async Task AddingAnAddressWithNoAccount_IsRefusedAndSaysWhatHasToHappenFirst()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
@@ -1342,18 +1344,14 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "newcomer@example.com";
|
||||
vaults.NewMemberEmail = "newcomer@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
|
||||
vaults.Members.ShouldHaveSingleItem("nobody joined — there was nobody to add");
|
||||
|
||||
var invitation = vaults.Invitations.ShouldHaveSingleItem();
|
||||
|
||||
invitation.Email.ShouldBe("newcomer@example.com");
|
||||
invitation.IsPending.ShouldBeTrue();
|
||||
invitation.State.ShouldContain("Nothing was sent");
|
||||
|
||||
vaults.Status.ShouldContain("cannot send mail");
|
||||
vaults.Status.ShouldContain("newcomer@example.com");
|
||||
vaults.Status.ShouldContain("sign in here once");
|
||||
vaults.Status.ShouldContain("Nothing is held for them");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -1361,13 +1359,12 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
/// The regression this whole path was rewritten for. An account exists from its owner's first
|
||||
/// authenticated request and publishes no key until they choose a passphrase on their own machine,
|
||||
/// and the directory omits it for that entire window — an entry exists to be wrapped to, and this
|
||||
/// one has nothing to wrap. Reading that silence as "there is no such account" meant ADD quietly
|
||||
/// issued an invitation instead: the members list did not change, the screen said they had no
|
||||
/// account here, and they only actually joined on the next hourly sweep.
|
||||
/// one has nothing to wrap. Reading that silence as "there is no such account" meant ADD refused
|
||||
/// somebody who was standing right there.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the assertion is that they are a <em>member</em>, not an invitation, and that the row says
|
||||
/// what is true of them — no key, so nothing can be shared with them yet.
|
||||
/// So the assertion is that they are a member, and that the row says what is true of them — no key,
|
||||
/// so nothing can be shared with them yet.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
@@ -1380,11 +1377,9 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "carol@example.com";
|
||||
vaults.NewMemberEmail = "carol@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Invitations.ShouldBeEmpty("they have an account here, so there is nothing to invite");
|
||||
|
||||
vaults.Members.Count.ShouldBe(2, vaults.Status);
|
||||
|
||||
var member = vaults.Members.Single(row => row.UserId == colleague);
|
||||
@@ -1399,12 +1394,13 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half of the pair above: an address with no account at all still falls through to an
|
||||
/// invitation. It is the server that decides which, so this proves the fall-through survived being
|
||||
/// moved behind it rather than being replaced by an error.
|
||||
/// The refusal does not clear the box, and that is the half worth pinning separately. A message
|
||||
/// telling somebody to come back once that person has signed in is a message they act on later — with
|
||||
/// the address gone they would have to find it again, and the natural reading of an emptied box is
|
||||
/// that the add went through.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AddingAnAddressWithNoAccount_StillInvitesRatherThanFailing()
|
||||
public async Task AnAddressThatWasRefused_IsStillInTheBox()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
@@ -1412,36 +1408,10 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "stranger@example.com";
|
||||
vaults.NewMemberEmail = "stranger@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
|
||||
vaults.Invitations.ShouldHaveSingleItem().Email.ShouldBe("stranger@example.com");
|
||||
}
|
||||
|
||||
/// <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 vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "newcomer@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedInvitation = vaults.Invitations.ShouldHaveSingleItem();
|
||||
|
||||
await vaults.RevokeInvitationCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Invitations.ShouldHaveSingleItem().State.ShouldBe("withdrawn");
|
||||
vaults.Status.ShouldContain("Withdrew the invitation");
|
||||
vaults.NewMemberEmail.ShouldBe("stranger@example.com");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
@@ -468,108 +468,6 @@ 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());
|
||||
@@ -670,15 +568,4 @@ 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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user