Public Access
Merge main into the desktop redesign branch
Four conflicts. Three were two people adding to the same spot, and one was a real collision: main gave the connect bar a REMEMBER tick in the same pass that this branch took the connect bar off the hosts screen. REMEMBER is now in the drawer, beside the password box it qualifies. Nothing about the feature changed — RemembersConnectPassword, its refusal to fire until the remote has accepted the password, and the six tests over it are main's untouched — only where it is drawn. The move improves it slightly and it is worth saying why rather than claiming a merge was neutral: the bar had one row and had to fit the box, the tick, the authentication note and CONNECT along it, which is why the tick was a bare "REMEMBER" in tracked capitals. A column has room to put it under the box as a sentence, where it reads as a property of the password rather than as a fourth control in a row. MainWindowViewModel: both sides added members after ShowTerminal — the desktop's three fixed tabs here, the phone's connect menu on main. They do not interact, so both are kept, each under its own heading. TeamsScreen: main added the team's own RENAME, HAND OVER and ARCHIVE, a member's LastActive, the role a new member arrives as, and the KEY HOLDERS list. This branch had only bumped the file's font sizes a point. Resolved by taking main's file whole and re-running the bump over it, so the new controls join the scale rather than sitting a point below everything around them. README: both sides described a different head's third pass in the same paragraph. Both kept. Two things checked rather than assumed, because this branch moved the furniture the merged commits sit on. The chrome heights main's terminal work touched are the phone's, not the desktop's — 44, 42 and 24 are unchanged, so the layout harness's budget still describes the window. And main's keychain DELETE did not reach VaultScreen.axaml, whose header this branch rearranged, so the five buttons that overflowed at the larger type are still five. 2415 tests pass, up from 2369 by the 46 main brought.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1495,6 +1495,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,
|
||||
|
||||
@@ -303,20 +303,15 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
/// application is not running.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both branches are written out rather than compared against the constant itself. Asserting a
|
||||
/// constant against itself would pass however it were edited, and the whole point of this test is
|
||||
/// that a release build must not ship a developer's loopback address — or, since the split, that a
|
||||
/// debug build must not point a clone at production.
|
||||
/// The address is written out rather than compared against the constant itself. Asserting a constant
|
||||
/// against itself would pass however it were edited, and the whole point of this test is that no
|
||||
/// build ships a developer's loopback address.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void TheDefaultServerUrl_IsTheHostedDeployment_ExceptInADebugBuild()
|
||||
public void TheDefaultServerUrl_IsTheHostedDeployment_InEveryBuild()
|
||||
{
|
||||
#if DEBUG
|
||||
shell.ServerUrl.ShouldBe("http://localhost:5233");
|
||||
#else
|
||||
shell.ServerUrl.ShouldBe("https://ssh.dodotech.cloud");
|
||||
#endif
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -687,6 +682,82 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.Screen.ShouldBe(ShellScreen.Vault);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette
|
||||
/// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu
|
||||
/// was raised from is part of it — see <c>MainWindowViewModel.IsTerminalShowing</c>.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheConnectSheet_HidesTheRendererAndLeavesTheSurfaceUnderIt()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.OpenConnectSheetCommand.Execute(null);
|
||||
|
||||
shell.IsConnectSheetOpen.ShouldBeTrue();
|
||||
shell.IsTerminalShowing.ShouldBeFalse("the sheet draws over the renderer's rectangle");
|
||||
shell.IsTerminalSurface.ShouldBeTrue("the bar the sheet was raised from is on that surface");
|
||||
|
||||
shell.CloseConnectSheetCommand.Execute(null);
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The flag holds the renderer blank, so one set while a page was showing would be a sheet nobody can
|
||||
/// see keeping a terminal hidden that nothing would put back.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheConnectSheet_RefusesToOpenOverAPage()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Vault);
|
||||
|
||||
shell.OpenConnectSheetCommand.Execute(null);
|
||||
|
||||
shell.IsConnectSheetOpen.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Every entry on the menu navigates, and none of them closes the sheet itself: leaving the terminal
|
||||
/// surface is what lowers it. That is the guarantee worth a test — it is what makes routes nobody wrote
|
||||
/// the sheet for, like closing the last tab or locking, safe.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task LeavingTheTerminal_LowersTheConnectSheetHoweverItIsLeft()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
// The menu's own second entry: one screen, over one view model, with the kind of remote chosen by
|
||||
// the thing that navigates.
|
||||
shell.OpenConnectSheetCommand.Execute(null);
|
||||
shell.ShowFilesCommand.Execute(RemoteKind.Bucket);
|
||||
|
||||
shell.IsConnectSheetOpen.ShouldBeFalse();
|
||||
shell.IsBucketsShowing.ShouldBeTrue();
|
||||
|
||||
// And a route the sheet was never wired to: back to the terminal, open it, then end the only shell
|
||||
// there is.
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
shell.OpenConnectSheetCommand.Execute(null);
|
||||
shell.IsConnectSheetOpen.ShouldBeTrue();
|
||||
|
||||
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
|
||||
|
||||
shell.IsConnectSheetOpen.ShouldBeFalse("closing the last tab returns the surface to a page");
|
||||
shell.IsShowingPages.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A visible WebView with no pane in it reads as the application having broken, so this is the one
|
||||
/// transition that moves the surface back on its own.
|
||||
@@ -2590,6 +2661,123 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.Hosts[0].Host.CredentialId.ShouldBe(credentialId, "an unrelated edit must not drop the binding");
|
||||
}
|
||||
|
||||
// ---- Remembering a typed password ----
|
||||
|
||||
[Fact]
|
||||
public async Task RememberingATypedPassword_BindsItToTheHostSoItIsNotAskedForAgain()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.RemembersConnectPassword.ShouldBeFalse("storing a password stays a decision");
|
||||
|
||||
vault.ConnectPassword = "s3cret";
|
||||
vault.RemembersConnectPassword = true;
|
||||
|
||||
await ConnectAndRememberAsync(vault);
|
||||
|
||||
// An ordinary keychain credential, named after the host, and carrying no username of its own — the
|
||||
// connection that just succeeded used the host's, and pinning a copy of it here would stop following
|
||||
// the host.
|
||||
var stored = vault.Credentials.ShouldHaveSingleItem();
|
||||
stored.Label.ShouldBe("prod-db");
|
||||
stored.Credential.Password.ShouldBe("s3cret");
|
||||
stored.Credential.Username.ShouldBeNull();
|
||||
|
||||
var host = vault.Hosts.ShouldHaveSingleItem();
|
||||
host.Host.CredentialId.ShouldBe(stored.EntityId);
|
||||
host.Authentication.ShouldBe("credential");
|
||||
|
||||
// The box has nothing left to hold and nothing left to ask, and the tick does not carry over to
|
||||
// whatever host is selected next.
|
||||
vault.ConnectPassword.ShouldBeEmpty();
|
||||
vault.RemembersConnectPassword.ShouldBeFalse();
|
||||
vault.SelectedHostAsksForAPassword.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARememberedPassword_SurvivesTheServerAndIsSentOnTheNextConnection()
|
||||
{
|
||||
// The whole point of storing it in the vault rather than on this machine: it is a property of the
|
||||
// host that reaches the other machines, not a box this one happens to remember filling in.
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
// One renderer for both connections. The page's token is spent on the first attach, so a second
|
||||
// FakeRenderer is answered with a 409 — which is the real renderer's behaviour too, and the reason
|
||||
// nothing else in this suite connects twice.
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.ConnectPassword = "s3cret";
|
||||
vault.RemembersConnectPassword = true;
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
await vault.SyncCommand.ExecuteAsync(null);
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.Credentials.ShouldHaveSingleItem().Credential.Password.ShouldBe("s3cret");
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.ConnectPassword.ShouldBeEmpty("nothing should need typing now");
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.Count.ShouldBe(2);
|
||||
ssh.Requests[1].Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARefusedConnection_RemembersNothing()
|
||||
{
|
||||
// The failure this feature could most easily cause: a typo bound to the host, which then stops asking
|
||||
// and cannot be connected to until somebody works out that the keychain is where the wrong password
|
||||
// now lives. Only a handshake the remote accepted is worth keeping.
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
ssh.Failure = new InvalidOperationException("authentication failed");
|
||||
|
||||
vault.ConnectPassword = "wrong";
|
||||
vault.RemembersConnectPassword = true;
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Credentials.ShouldBeEmpty();
|
||||
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
|
||||
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectingWithoutTheTick_StoresNothing()
|
||||
{
|
||||
// The other half of the decision, and the reason the typed box still exists: a one-off password on a
|
||||
// machine somebody will never open again must not end up synchronised to every device they own.
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.ConnectPassword = "s3cret";
|
||||
|
||||
await ConnectWithRendererAsync(vault);
|
||||
|
||||
vault.Credentials.ShouldBeEmpty();
|
||||
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
|
||||
vault.ConnectPassword.ShouldBe("s3cret", "the box is left as it was typed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RememberingIsIgnoredForAHostThatDoesNotAskForAPassword()
|
||||
{
|
||||
// A tick left over from a host that did ask must not manufacture a credential out of a stored one's
|
||||
// password — which is what reading the dialled secret without checking the binding would do.
|
||||
var vault = await ReadyToConnectAsync();
|
||||
await AddCredentialAsync(vault, "prod deploy", password: "s3cret");
|
||||
await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.RemembersConnectPassword = true;
|
||||
|
||||
await ConnectWithRendererAsync(vault);
|
||||
|
||||
vault.Credentials.ShouldHaveSingleItem("nothing should have been added to the keychain");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The reason the picker is one control rather than two. <c>HostSecret.TryValidate</c> refuses a host naming
|
||||
/// both a key and a credential, so two pickers would have been able to express the state and would have had
|
||||
@@ -5017,6 +5205,24 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.Status.ShouldContain("Connected", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same, for a connection that is expected to store its password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Without the status assertion, and that is the whole reason it is separate. Remembering writes two
|
||||
/// items and then pushes them, exactly as saving a host does, so the pass repaints the line with its own
|
||||
/// count — leaving "Connected" true of what happened and false of what the line says. What the connection
|
||||
/// actually did is asserted on the vault, which is where it is durable.
|
||||
/// </remarks>
|
||||
private async Task ConnectAndRememberAsync(VaultViewModel vault)
|
||||
{
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
ssh.Requests.ShouldNotBeEmpty("the password is only kept once a handshake has succeeded");
|
||||
}
|
||||
|
||||
/// <summary>An unlocked vault with one selected host and a renderer attached.</summary>
|
||||
private async Task<VaultViewModel> ReadyToConnectAsync()
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user