Public Access
Make the vault the thing you share, and ask a host which one it lives in
The teams screen listed teams that owned vaults, so sharing four servers with two
colleagues meant creating a team, then a vault inside it, then wrapping a key.
Two of those three steps are about a concept nobody arrives wanting. The screen
now lists vaults: naming one creates the membership list that carries it, named
after the vault and owned by you, and members, invitations, roles, hand-over and
key holders all hang off the vault they apply to.
Nothing on the server moved. VaultAccessService still resolves a shared vault
through team_membership and every membership call still names a team id — what
went is the requirement that anybody make one. The split the whole design rests
on is untouched and is still what the screen is built around: adding somebody
authorises the server to serve them, and only a machine holding the key can make
the vault readable. ADR 0009 keeps its decision and gains an addendum recording
which half of it a person is now asked about.
The one place the team resurfaces is a membership list carrying several vaults,
which this screen cannot produce and does not hide: the members section says so,
because "adding somebody here adds them there" is precisely the fact a
vault-shaped screen is in a position to conceal.
Two things left the interface and one arrived. Creating a team is gone, and so is
archiving one — it was only ever possible for a team owning no vaults, and a
screen whose rows are vaults has no row for one, so the button would have been
unreachable or always refused. The endpoint is unchanged and the screen states
the limit instead, since a vault cannot be deleted at all. The exception is a
create whose second call failed: cancelling that form archives the membership
list it left behind, which is a deliberate departure from this client's rule
against tidying up on the user's behalf, made because nothing else can reach it.
What arrived is PUT /api/v1/vaults/{id}. Without it the screen loses its only
editing action, since renaming the team behind a vault is invisible to everybody
who was never shown the team. It is gated on PermissionFlags.Admin — the line
UpdateTeamEndpoint already draws, because a name is what everybody in the vault
sees it called rather than part of its contents — and it renames the owning team
with it when that team carries nothing else, so the row an operator reads and the
name a user says cannot drift apart. The slug never moves, for the reason it does
not move on a team rename. The session edits its cached vault row rather than
replacing it with the response, which deliberately carries no wrapped key.
The host editor now asks which vault a host goes into, beside the name, while
adding and only where there is more than one vault to write to. It is a second
picker rather than the keychain screen's reused, and the two selections are
separate on purpose: that one is a standing preference about where new items go,
this is a field of the host in front of you, and binding both to one selection
would mean a click on the other screen could move a half-typed host. An existing
host is not offered it at all rather than offered it disabled — the two vaults
are encrypted under different keys, so moving an item is a delete and a retype.
That forced a fix worth naming. The group picker was built from the active
vault's groups whatever vault the host was being filed into, so a host put in a
shared vault could be filed under a group only its author can resolve — a
colleague would see it filed under nothing, which is the quietest kind of wrong.
Groups are now kept per vault and the picker follows the vault choice.
Two renames, because the pair they would otherwise have made is a bug farm:
ShellScreen.Vault became Keychain and VaultScreen became KeychainScreen, which is
what the rail has always labelled that screen, leaving Vault for one vault's
contents and Vaults for the vaults themselves. The enum values are unchanged;
NavRail.axaml writes them as x:Static literals.
1536 tests pass, seven more than before. Five are new on the server — the rename
endpoint's success, the team it does and does not take with it, the two refusals
and the empty name — and the client suite gains six and folds four together,
having lost the two about archiving a team.
This commit is contained in:
@@ -99,6 +99,11 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
|
||||
// Enrolled. The listing is gated on Read rather than Share — every member can already see the
|
||||
// sharing graph — and the two writes are gated on Share inside the handler, which this table
|
||||
// cannot see. See VaultGrantEndpoints.
|
||||
// Authenticated, alone among the vault routes: renaming touches no key material, so refusing
|
||||
// somebody who has not published an identity key would be refusing them for an unrelated
|
||||
// reason. Gated on Admin inside the handler, which this table cannot see.
|
||||
"PUT /api/v1/vaults/{vaultId:guid} name=RenameVault tags=Vaults policies=Authenticated anon=False",
|
||||
|
||||
"GET /api/v1/vaults/{vaultId:guid}/grants name=ListVaultGrants tags=Vaults policies=Enrolled anon=False",
|
||||
"POST /api/v1/vaults/{vaultId:guid}/grants name=IssueVaultGrant tags=Vaults policies=Enrolled anon=False",
|
||||
"DELETE /api/v1/vaults/{vaultId:guid}/grants/{userId:guid} name=RevokeVaultGrant tags=Vaults policies=Enrolled anon=False",
|
||||
|
||||
@@ -252,6 +252,134 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
.Role.ShouldBe(TeamMemberRole.Owner);
|
||||
}
|
||||
|
||||
// ---- Renaming a vault ----
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The rename the vaults screen offers, and the assertion that matters is the second one: the team is
|
||||
/// renamed with the vault when it owns nothing else. A vault made from that screen gets a team of its
|
||||
/// own that nobody was ever shown, so a rename that moved only the vault would leave the operator, the
|
||||
/// logs and the database naming it something no user recognises.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The slug is asserted unchanged in the same breath. It is unique only among live teams, so a rename
|
||||
/// that moved it could take one an archived team is still holding — the same limit
|
||||
/// <c>UpdateTeamRequest</c> records.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RenamingAVault_RenamesTheTeamBehindItAndLeavesItsSlugAlone()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("vault-rename-owner");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Platform secrets");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var response = await owner.PutContractAsync(
|
||||
VaultUrl(vaultId), new UpdateVaultRequest("Platform"));
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var renamed = (await response.Content.ReadContractAsync<VaultSummary>())!;
|
||||
|
||||
renamed.Name.ShouldBe("Platform");
|
||||
|
||||
var me = await ReadAsync<MeResponse>(owner, MeUrl);
|
||||
|
||||
me.Vaults.Single(vault => vault.VaultId == vaultId).Name.ShouldBe("Platform");
|
||||
|
||||
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
|
||||
var after = listed.Single(row => row.TeamId == team.TeamId);
|
||||
|
||||
after.Name.ShouldBe("Platform");
|
||||
after.Slug.ShouldBe(team.Slug);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A team carrying several vaults has a name of its own that somebody chose, so renaming one of its
|
||||
/// vaults must not take it. This is the arrangement the vaults screen cannot make and does not hide;
|
||||
/// the server draws the same line.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RenamingOneOfSeveralVaults_LeavesTheTeamsOwnNameAlone()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("vault-rename-shared-owner");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Platform Engineering");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var response = await owner.PutContractAsync(
|
||||
VaultUrl(vaultId), new UpdateVaultRequest("Production"));
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
|
||||
|
||||
listed.Single(row => row.TeamId == team.TeamId).Name.ShouldBe("Platform Engineering");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Admin rather than Write, and the line is the one the team rename draws: a name is what everybody in
|
||||
/// the vault sees it called, so a member who may add hosts to it may not rename it out from under them.
|
||||
/// A member is refused with 403 rather than 404 because the vault is visible to them, so naming the
|
||||
/// reason leaks nothing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task APlainMember_CannotRenameAVaultTheyCanWriteTo()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("vault-rename-limits-owner", "vrowner@example.com");
|
||||
var member = await EnrolledClientAsync("vault-rename-limits-member", "vrmember@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Limits");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
var entry = await LookupAsync(owner, "vrmember@example.com");
|
||||
|
||||
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
||||
|
||||
var response = await member.PutContractAsync(
|
||||
VaultUrl(vaultId), new UpdateVaultRequest("Theirs now"));
|
||||
|
||||
await ShouldBeProblemAsync(response, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An outsider gets 404 rather than 403, which is the rule <c>IVaultAccessService</c> states: a
|
||||
/// distinct "exists but forbidden" answer is an existence oracle for other tenants' vault ids.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RenamingSomebodyElsesVault_IsNotFound()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("vault-rename-outsider-owner");
|
||||
var outsider = await EnrolledClientAsync("vault-rename-outsider");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Private");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var response = await outsider.PutContractAsync(
|
||||
VaultUrl(vaultId), new UpdateVaultRequest("Mine now"));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An empty name is refused rather than stored, because a vault has to be pickable by name before
|
||||
/// anything in it is decrypted — one called nothing is one nobody can choose.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RenamingAVaultToNothing_IsRefused()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("vault-rename-empty-owner");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Named");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
|
||||
var response = await owner.PutContractAsync(VaultUrl(vaultId), new UpdateVaultRequest(" "));
|
||||
|
||||
await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeam);
|
||||
}
|
||||
|
||||
// ---- Archiving ----
|
||||
|
||||
/// <remarks>
|
||||
@@ -1092,6 +1220,8 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
|
||||
private static string TeamVaultsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/vaults";
|
||||
|
||||
private static string VaultUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}";
|
||||
|
||||
/// <summary>An address no account holds, uniquified because the container is shared.</summary>
|
||||
private static string NewAddress() => $"invitee-{Guid.CreateVersion7():N}@example.com";
|
||||
|
||||
|
||||
@@ -547,7 +547,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
// ---- The vault screen ----
|
||||
|
||||
[Fact]
|
||||
public async Task TheVaultScreenFitsInEveryCategory()
|
||||
public async Task TheKeychainScreenFitsInEveryCategory()
|
||||
{
|
||||
foreach (var section in new[]
|
||||
{
|
||||
@@ -565,7 +565,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
/// wide — the narrowest column any form in this application has to fit into.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheVaultScreenFitsWithTheKeyEditorOpen()
|
||||
public async Task TheKeychainScreenFitsWithTheKeyEditorOpen()
|
||||
{
|
||||
vault.NewKeyCommand.Execute(null);
|
||||
vault.IsEditingKey.ShouldBeTrue();
|
||||
@@ -579,7 +579,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheVaultScreenFitsWithThePasswordEditorOpen()
|
||||
public async Task TheKeychainScreenFitsWithThePasswordEditorOpen()
|
||||
{
|
||||
vault.NewCredentialCommand.Execute(null);
|
||||
vault.IsEditingCredential.ShouldBeTrue();
|
||||
@@ -595,7 +595,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
/// nobody was told about.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheVaultScreenFitsWithTheGenerateFormOpen()
|
||||
public async Task TheKeychainScreenFitsWithTheGenerateFormOpen()
|
||||
{
|
||||
vault.NewGeneratedKeyCommand.Execute(null);
|
||||
vault.IsGeneratingKey.ShouldBeTrue();
|
||||
@@ -820,7 +820,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
/// application.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheVaultScreenFitsWithADeletionInQuestion()
|
||||
public async Task TheKeychainScreenFitsWithADeletionInQuestion()
|
||||
{
|
||||
var keyId = vault.Keys[0].EntityId;
|
||||
|
||||
@@ -1607,13 +1607,11 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
bytesPerSecond,
|
||||
failure)));
|
||||
|
||||
/// <summary>Lays the vault screen out at the width it gets once the nav rail has taken its column.</summary>
|
||||
/// <summary>Lays the vaults 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.
|
||||
/// Its right-hand column is the narrowest measured here: the window's minimum is 1016, the nav rail
|
||||
/// takes 190 and the vault 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
|
||||
@@ -1623,49 +1621,32 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task TheTeamsScreen_FitsWithEveryListPopulated() =>
|
||||
OnTheTeamsScreenAsync(
|
||||
teams => { },
|
||||
public Task TheVaultsScreen_FitsWithEveryListPopulated() =>
|
||||
OnTheVaultsScreenAsync(
|
||||
vaults => { },
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the teams screen with members, invitations and key holders"));
|
||||
.ShouldBeEmpty("the vaults 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),
|
||||
public Task TheVaultsScreen_FitsWhileRenamingAVault() =>
|
||||
OnTheVaultsScreenAsync(
|
||||
vaults => vaults.RenameVaultCommand.Execute(null),
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the teams screen with the rename form open"));
|
||||
.ShouldBeEmpty("the vaults screen with the rename form open"));
|
||||
|
||||
/// <remarks>
|
||||
/// The name-a-vault form is in the left column under the team list, and it is the taller of the two
|
||||
/// forms that can appear there — one field, but two sentences under it. Worth its own case because the
|
||||
/// column is 268 wide and both sentences wrap.
|
||||
/// The name-a-vault form is in the left column under the vault list. Worth its own case because the
|
||||
/// column is 268 wide and the sentence under the field wraps.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task TheTeamsScreen_FitsWithTheNewVaultFormOpen() =>
|
||||
OnTheTeamsScreenAsync(
|
||||
teams => teams.NewVaultInItsOwnTeamCommand.Execute(null),
|
||||
public Task TheVaultsScreen_FitsWithTheNewVaultFormOpen() =>
|
||||
OnTheVaultsScreenAsync(
|
||||
vaults => vaults.NewVaultCommand.Execute(null),
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the teams screen with the new-vault form open"));
|
||||
|
||||
/// <remarks>
|
||||
/// Both forms at once, which is reachable: NEW at the top of the team list and New vault… in the tab
|
||||
/// strip's menu arm different forms and neither closes the other. Together they are the most the left
|
||||
/// column can be asked to hold.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task TheTeamsScreen_FitsWithBothCreateFormsOpen() =>
|
||||
OnTheTeamsScreenAsync(
|
||||
teams =>
|
||||
{
|
||||
teams.NewTeamCommand.Execute(null);
|
||||
teams.NewVaultInItsOwnTeamCommand.Execute(null);
|
||||
},
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the teams screen with both create forms open"));
|
||||
.ShouldBeEmpty("the vaults screen with the new-vault form open"));
|
||||
|
||||
/// <remarks>
|
||||
/// The armed confirmation carries two sentences of prose and replaces the header's buttons. It is the
|
||||
@@ -1673,35 +1654,59 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
/// key-holders list off the bottom.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task TheTeamsScreen_FitsWhileConfirmingAnArchive() =>
|
||||
OnTheTeamsScreenAsync(
|
||||
teams => teams.ArchiveTeamCommand.Execute(null),
|
||||
public Task TheVaultsScreen_FitsWhileConfirmingAHandOver() =>
|
||||
OnTheVaultsScreenAsync(
|
||||
vaults =>
|
||||
{
|
||||
vaults.SelectedMember = vaults.Members.First(member => !member.IsSelf);
|
||||
vaults.HandOverCommand.Execute(null);
|
||||
},
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the teams screen with the archive confirmation armed"));
|
||||
.ShouldBeEmpty("the vaults screen with the hand-over 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.
|
||||
/// <para>
|
||||
/// A real <c>VaultsViewModel</c> over this suite's own unlocked session and a stub server. Both halves
|
||||
/// are needed and they answer different questions: the vault list is the session's, and who is in each
|
||||
/// vault is the server's.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A shared vault is created into the session first, because a session that has only ever been unlocked
|
||||
/// offline holds one personal vault — and the personal vault draws none of what this screen is for. It
|
||||
/// is created through the real <c>CreateTeamVaultAsync</c> rather than poked into the cache, so the row
|
||||
/// being measured is one the application could actually produce.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Selected before the second load rather than after it, so the members read is the awaited one: a
|
||||
/// selection assignment starts a read nothing can wait for, and measuring a window while it was still
|
||||
/// in flight would certify a screen with empty lists.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task OnTheTeamsScreenAsync(
|
||||
Action<TeamsViewModel> arrange,
|
||||
private async Task OnTheVaultsScreenAsync(
|
||||
Action<VaultsViewModel> arrange,
|
||||
Action<Window> assert)
|
||||
{
|
||||
using var teamServer = new StubTeamServer();
|
||||
|
||||
var teams = new TeamsViewModel(() => teamServer, () => null);
|
||||
await session.CreateTeamVaultAsync(
|
||||
teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
|
||||
|
||||
await teams.LoadAsync(Token);
|
||||
var vaults = new VaultsViewModel(() => teamServer, () => session);
|
||||
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
vaults.SelectedVault = vaults.Vaults.First(row => row.IsShared);
|
||||
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
vaults.Members.ShouldNotBeEmpty("there is nothing to measure otherwise");
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
arrange(teams);
|
||||
arrange(vaults);
|
||||
|
||||
var screen = new TeamsScreen { DataContext = teams };
|
||||
var screen = new VaultsScreen { DataContext = vaults };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
@@ -1721,11 +1726,11 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
private Task MeasureVaultAsync(Action<IReadOnlyList<string>> assert) =>
|
||||
OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
|
||||
|
||||
private Task OnTheVaultAsync(Action<VaultScreen, Window> body) =>
|
||||
private Task OnTheVaultAsync(Action<KeychainScreen, Window> body) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new VaultScreen { DataContext = vault };
|
||||
var screen = new KeychainScreen { DataContext = vault };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
|
||||
@@ -7,15 +7,20 @@ 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.
|
||||
/// The least server a <c>VaultsViewModel</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.
|
||||
/// The vaults screen draws its list from the session and everything under it from the server: who is in a
|
||||
/// vault, who has been invited, and who holds a key are all read on open, and the suite's
|
||||
/// <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>
|
||||
/// It does answer <see cref="CreateTeamVaultAsync"/>, unlike the other writes, because that is how the
|
||||
/// suite gets a shared vault into the session at all — an offline layout test has no other way to reach
|
||||
/// the state this screen exists to draw.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The rows are deliberately the <em>long</em> ones. A layout suite that measured "Bob" in a column sized
|
||||
@@ -61,6 +66,9 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
|
||||
/// <summary>The vault whose key holders are listed, so a test can select it.</summary>
|
||||
internal static Guid TeamVaultId => VaultId;
|
||||
|
||||
/// <summary>The membership list behind that vault, so a test can create it in the session.</summary>
|
||||
internal static Guid SharedTeamId => TeamId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<TeamSummary>>(
|
||||
@@ -209,10 +217,36 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
|
||||
Guid invitationId,
|
||||
CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>
|
||||
/// Accepts the vault, so a layout test can put a shared one into the session it is drawing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The client's own id and wrapped key are echoed back, exactly as the real endpoint answers: the key
|
||||
/// was generated on this machine and the session adopts its own copy, so anything else here would be
|
||||
/// either discarded or a vault nobody could open.
|
||||
/// <para>
|
||||
/// It comes back owing a rekey, which is not decoration: that is the longer of the two lines a vault row
|
||||
/// can carry, and this suite exists to measure the long one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Task<VaultSummary> CreateTeamVaultAsync(
|
||||
Guid teamId,
|
||||
CreateTeamVaultRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new VaultSummary(
|
||||
request.VaultId,
|
||||
request.Name,
|
||||
IsPersonal: false,
|
||||
TeamId: teamId,
|
||||
KeyGeneration: 1,
|
||||
Permissions: 31,
|
||||
request.WrappedVaultKey,
|
||||
RekeyRequired: true));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<VaultSummary> RenameVaultAsync(
|
||||
Guid vaultId,
|
||||
UpdateVaultRequest request,
|
||||
CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -496,6 +496,48 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
||||
return Task.FromResult(vault);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The owning team is renamed with the vault when it owns nothing else, exactly as the real service
|
||||
/// does it — a fake that moved only the vault would let a test pass while the two names disagreed,
|
||||
/// which is the state the server code goes out of its way to avoid.
|
||||
/// </remarks>
|
||||
public Task<VaultSummary> RenameVaultAsync(
|
||||
Guid vaultId,
|
||||
UpdateVaultRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (personalVault is { } personal && personal.VaultId == vaultId)
|
||||
{
|
||||
personalVault = personal with { Name = request.Name };
|
||||
|
||||
return Task.FromResult(personalVault);
|
||||
}
|
||||
|
||||
if (!teamVaults.TryGetValue(vaultId, out var vault))
|
||||
{
|
||||
throw new DodoSshApiException(
|
||||
System.Net.HttpStatusCode.NotFound, ProblemCodes.InvalidTeam, "No such vault.");
|
||||
}
|
||||
|
||||
var renamed = vault with { Name = request.Name };
|
||||
|
||||
teamVaults[vaultId] = renamed;
|
||||
|
||||
if (renamed.TeamId is { } teamId
|
||||
&& !teamVaults.Values.Any(other => other.TeamId == teamId && other.VaultId != vaultId))
|
||||
{
|
||||
var index = teams.FindIndex(team => team.TeamId == teamId);
|
||||
|
||||
if (index >= 0)
|
||||
{
|
||||
teams[index] = teams[index] with { Name = request.Name };
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(renamed);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
|
||||
string email,
|
||||
|
||||
@@ -623,11 +623,11 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Vault);
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeFalse();
|
||||
shell.IsShowingPages.ShouldBeTrue();
|
||||
shell.IsVaultShowing.ShouldBeTrue();
|
||||
shell.IsKeychainShowing.ShouldBeTrue();
|
||||
|
||||
// The session is untouched. Navigating away from a terminal is not a way to end one; only closing
|
||||
// its tab is.
|
||||
@@ -671,7 +671,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Vault);
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
||||
shell.IsTerminalShowing.ShouldBeFalse();
|
||||
|
||||
shell.ShowTerminalCommand.Execute(null);
|
||||
@@ -681,7 +681,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
// The page underneath is remembered, not reset. Going to the terminal and back is navigation, and
|
||||
// navigation that forgets where you were is how a four-button bar becomes annoying.
|
||||
shell.Screen.ShouldBe(ShellScreen.Vault);
|
||||
shell.Screen.ShouldBe(ShellScreen.Keychain);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -934,7 +934,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Vault);
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
||||
|
||||
shell.OpenConnectSheetCommand.Execute(null);
|
||||
|
||||
@@ -1253,7 +1253,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
// Navigating away during the connection, which is the case this most exists for: the connection goes
|
||||
// on, the tab stays selected, and nothing in the strip claims to be on screen.
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Vault);
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
||||
|
||||
tab.IsShowing.ShouldBeFalse();
|
||||
tab.IsSelected.ShouldBeTrue("navigating away is not deselecting");
|
||||
@@ -1346,16 +1346,16 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
LitEntries().ShouldBe(0);
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Vault);
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
||||
LitEntries().ShouldBe(1);
|
||||
shell.IsVaultShowing.ShouldBeTrue();
|
||||
shell.IsKeychainShowing.ShouldBeTrue();
|
||||
|
||||
int LitEntries() => new[]
|
||||
{
|
||||
shell.IsHostsShowing,
|
||||
shell.IsTransfersShowing,
|
||||
shell.IsVaultShowing,
|
||||
shell.IsTeamShowing,
|
||||
shell.IsKeychainShowing,
|
||||
shell.IsVaultsShowing,
|
||||
shell.IsPreferencesShowing,
|
||||
}.Count(lit => lit);
|
||||
}
|
||||
@@ -2129,7 +2129,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
/// rather than four separate lists.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheVaultScreenOpensOnEverythingAndTheRailMovesBetweenCategories()
|
||||
public async Task TheKeychainScreenOpensOnEverythingAndTheRailMovesBetweenCategories()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
@@ -2223,7 +2223,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
/// nothing on screen to say it is there.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SwitchingSectionIsRefusedWhileAVaultScreenEditorIsOpen()
|
||||
public async Task SwitchingSectionIsRefusedWhileAKeychainScreenEditorIsOpen()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
@@ -2294,7 +2294,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
/// can see, because it is holding their private key.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostEditorAndAVaultScreenEditorCanBeOpenTogether()
|
||||
public async Task TheHostEditorAndAKeychainScreenEditorCanBeOpenTogether()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
@@ -2317,7 +2317,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
/// the host editor, which does not.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task OnlyOneVaultScreenEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped()
|
||||
public async Task OnlyOneKeychainScreenEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
@@ -2349,7 +2349,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditingAnExistingVaultItem_IsRefusedByTheOtherVaultScreenEditorToo()
|
||||
public async Task EditingAnExistingVaultItem_IsRefusedByTheOtherKeychainScreenEditorToo()
|
||||
{
|
||||
// The Edit commands are a second door into the same screen, and guarding only the Add ones would
|
||||
// leave it wide open.
|
||||
@@ -3055,7 +3055,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
/// longer the host editor, which is a different screen and has nothing to lose by the rail moving.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheCredentialEditorGuardsTheVaultScreensRail()
|
||||
public async Task TheCredentialEditorGuardsTheKeychainScreensRail()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
@@ -3195,7 +3195,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.KnownHosts);
|
||||
shell.IsKnownHostsShowing.ShouldBeTrue();
|
||||
shell.IsVaultShowing.ShouldBeFalse();
|
||||
shell.IsKeychainShowing.ShouldBeFalse();
|
||||
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
|
||||
|
||||
@@ -1,687 +0,0 @@
|
||||
using DodoSSH.Client.Session;
|
||||
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
|
||||
// see the csproj for why it is shared rather than reimplemented.
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Teams, from the side that holds the keys: create one, add somebody, and wrap a vault key to them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The reason this suite exists rather than leaving teams to the server's own tests is that the
|
||||
/// interesting half is not on the server. Adding a member is a row; <b>sharing is a decision the client
|
||||
/// makes about whether to trust a public key the server just handed it</b>, and that decision is what
|
||||
/// stands between an end-to-end encrypted vault and one the operator can read by answering a directory
|
||||
/// lookup with a key of their own.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the fake server keeps a real key log — chained with the same <c>KeyLogChain</c> the server uses —
|
||||
/// and can be told to corrupt it. A test that only ever saw a well-formed log would be checking that
|
||||
/// sharing works, not that verification does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TeamSharingTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private readonly FakeVaultServer server = new();
|
||||
private readonly FakeSshConnectionFactory ssh = new();
|
||||
|
||||
private string directory = null!;
|
||||
private ClientCacheFactory caches = null!;
|
||||
private TerminalWorkspace workspace = null!;
|
||||
private VaultKnownHostStore knownHosts = null!;
|
||||
private FakeDeviceKeyStore deviceKeys = null!;
|
||||
private MainWindowViewModel shell = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
directory = Path.Combine(Path.GetTempPath(), $"dodossh-teams-{Guid.CreateVersion7():N}");
|
||||
|
||||
var paths = new ClientPaths(directory);
|
||||
|
||||
caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
||||
knownHosts = new VaultKnownHostStore();
|
||||
deviceKeys = new FakeDeviceKeyStore();
|
||||
|
||||
workspace = new TerminalWorkspace(
|
||||
new InMemoryTerminalAssetProvider(
|
||||
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
ssh,
|
||||
TimeProvider.System);
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
paths,
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
deviceKeys,
|
||||
(_, _) => Task.FromResult<IVaultServer>(server),
|
||||
TimeProvider.System,
|
||||
NSubstitute.Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
caches.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A cache file the process has not finished releasing. The directory is under the temp path
|
||||
// and named per run, so leaving it costs a few kilobytes and never collides.
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The whole point of a team, in one test. Note what the status line says after the add and before
|
||||
/// the share: adding somebody grants them nothing readable, and the interface has to say so rather
|
||||
/// than let a user believe the credential is already with their colleague.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CreatingATeamAndSharingItsVault_WrapsTheKeyToTheOtherMember()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
|
||||
await CreateVaultAsync(teams, "Platform secrets");
|
||||
teams.Vaults.Count.ShouldBe(1, teams.Status);
|
||||
|
||||
teams.InviteEmail = "bob@example.com";
|
||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
teams.Members.Count.ShouldBe(2, teams.Status);
|
||||
teams.Status.ShouldContain("cannot read anything yet");
|
||||
|
||||
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
|
||||
teams.SelectedVault = teams.Vaults[0];
|
||||
|
||||
await teams.ShareVaultCommand.ExecuteAsync(null);
|
||||
|
||||
var vaultId = teams.Vaults[0].VaultId;
|
||||
|
||||
server.IssuedGrants.ShouldContainKey((vaultId, colleague));
|
||||
teams.Status.ShouldContain("Shared");
|
||||
|
||||
// The one thing verification cannot promise, said in the same breath as the success.
|
||||
teams.Status.ShouldContain("fingerprint", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The test this whole design exists for. A server that wants to read a team's vault only has to
|
||||
/// answer one directory lookup with a key it holds the private half of — so the client reads the
|
||||
/// append-only key log, verifies its chain, and refuses to wrap anything unless the key it was
|
||||
/// offered is in there unchanged.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing may be sent. A refusal that still issued the grant, or that issued it on a retry, would be
|
||||
/// worse than no check at all, because the interface would have said it was verified.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATamperedKeyLog_StopsTheShareRatherThanWarningAboutIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await CreateVaultAsync(teams, "Platform secrets");
|
||||
|
||||
teams.InviteEmail = "mallory@example.com";
|
||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
|
||||
teams.SelectedVault = teams.Vaults[0];
|
||||
|
||||
server.CorruptKeyLog = true;
|
||||
|
||||
await teams.ShareVaultCommand.ExecuteAsync(null);
|
||||
|
||||
server.IssuedGrants.ShouldBeEmpty();
|
||||
teams.Status.ShouldContain("Did not share");
|
||||
teams.Status.ShouldContain("key log");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A vault created here is usable here, without a relock. The key was generated in this process, so
|
||||
/// making the user lock and unlock to reach the vault they just made would be asking them to work
|
||||
/// around bookkeeping.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATeamVaultCreatedHere_IsImmediatelyReadableAndWritable()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await CreateVaultAsync(teams, "Platform secrets");
|
||||
|
||||
var vaultId = teams.Vaults[0].VaultId;
|
||||
var session = shell.Vault!.Session;
|
||||
|
||||
session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId);
|
||||
|
||||
// And it is offered as somewhere to file a new item, which is what makes it worth having.
|
||||
await shell.Vault.LoadAsync(Token);
|
||||
|
||||
shell.Vault.TargetVaults.Select(choice => choice.VaultId).ShouldContain(vaultId);
|
||||
shell.Vault.HasVaultChoice.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Filing into a team vault has to be chosen and has to stick. The bug this guards is the obvious
|
||||
/// one: an editor that read the picker at save time rather than at open time, so changing the picker
|
||||
/// with a half-typed host on screen would move it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AHostFiledIntoATeamVault_StaysThere()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await CreateVaultAsync(teams, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var teamVaultId = teams.Vaults[0].VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.SelectedTargetVault =
|
||||
vault.TargetVaults.Single(choice => choice.VaultId == teamVaultId);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
// Moved back after the editor opened. The host must still land in the team's vault.
|
||||
vault.SelectedTargetVault =
|
||||
vault.TargetVaults.First(choice => choice.VaultId != teamVaultId);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var row = vault.Hosts.Single(
|
||||
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
|
||||
row.VaultId.ShouldBe(teamVaultId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The mirror image of the host test above, and it goes the other way on purpose. A host filed into a
|
||||
/// team vault has to stay there, because hosts are read across every readable vault and so come back.
|
||||
/// Tags are not — the editable list is the active vault's alone, like groups and buckets — so a tag
|
||||
/// filed anywhere else would be created, pushed, reported as added and then invisible, with nothing on
|
||||
/// the keychain screen able to rename or delete it and no active-vault switcher to go and find it with.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATagIgnoresTheTargetPicker_BecauseItsListOnlyEverShowsOneVault()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
await CreateVaultAsync(teams, "Platform secrets");
|
||||
|
||||
var teamVaultId = teams.Vaults[0].VaultId;
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.HasVaultChoice.ShouldBeTrue("this test is meaningless with one vault");
|
||||
|
||||
vault.SelectedTargetVault = vault.TargetVaults.Single(
|
||||
choice => choice.VaultId == teamVaultId);
|
||||
|
||||
vault.NewTagCommand.Execute(null);
|
||||
vault.TagEditorLabel = "eu-west-1";
|
||||
await vault.SaveTagCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Tags.ShouldHaveSingleItem().Label
|
||||
.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 CreateVaultAsync(teams, "Platform secrets");
|
||||
|
||||
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 CreateVaultAsync(teams, "Platform secrets");
|
||||
|
||||
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>
|
||||
/// <para>
|
||||
/// The regression this whole path was rewritten for. An account exists from its owner's first
|
||||
/// authenticated request and publishes no key until they choose a passphrase on their own machine,
|
||||
/// and the directory omits it for that entire window — an entry exists to be wrapped to, and this
|
||||
/// one has nothing to wrap. Reading that silence as "there is no such account" meant ADD MEMBER
|
||||
/// quietly issued an invitation instead: the members list did not change, the screen said they had
|
||||
/// no account here, and they only actually joined on the next hourly sweep.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the assertion is that they are a <em>member</em>, not an invitation, and that the row says
|
||||
/// what is true of them — no key, so nothing can be shared with them yet.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AddingAnAccountThatHasNotEnrolled_MakesThemAMemberWithNoKey()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
var colleague = server.AddUnenrolledAccount("carol@example.com", "Carol Example");
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
|
||||
teams.InviteEmail = "carol@example.com";
|
||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
teams.Invitations.ShouldBeEmpty("they have an account here, so there is nothing to invite");
|
||||
|
||||
teams.Members.Count.ShouldBe(2, teams.Status);
|
||||
|
||||
var member = teams.Members.Single(row => row.UserId == colleague);
|
||||
|
||||
member.Email.ShouldBe("carol@example.com");
|
||||
|
||||
// The label the user asked to see, and the reason SHARE KEY is not the next step.
|
||||
member.KeyState.ShouldContain("no key yet");
|
||||
|
||||
teams.Status.ShouldContain("Added");
|
||||
teams.Status.ShouldContain("no key yet");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half of the pair above: an address with no account at all still falls through to an
|
||||
/// invitation. It is the server that decides which, so this proves the fall-through survived being
|
||||
/// moved behind it rather than being replaced by an error.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AddingAnAddressWithNoAccount_StillInvitesRatherThanFailing()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await CreateTeamAsync(teams, "Platform", "platform");
|
||||
|
||||
teams.InviteEmail = "stranger@example.com";
|
||||
await teams.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
teams.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
|
||||
teams.Invitations.ShouldHaveSingleItem().Email.ShouldBe("stranger@example.com");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A withdrawn invitation stays on the list saying it was withdrawn, rather than vanishing. One that
|
||||
/// disappeared would read as never having been sent, which is the same thing the screen looks like
|
||||
/// before anybody does anything.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task WithdrawingAnInvitation_LeavesItListedAsWithdrawn()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var 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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A reload rebuilds the team list and reselects, so a reload that changed the selection — creating
|
||||
/// the first team is exactly that — used to leave two reads of the same team in flight: the one the
|
||||
/// reload awaits, and one the selection handler started on its own. Both clear the member list and
|
||||
/// then both append to it, so every member was drawn twice. On a team nobody has been added to yet,
|
||||
/// whose only member is its owner, that read as the owner being in the team twice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Counted rather than inferred from the list, and the gate is why: against a fake that answers from
|
||||
/// memory each read finishes before the next begins, so the duplicate never appears and the bug
|
||||
/// survives the test. Holding the read open is what makes this behave like a server.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CreatingATeam_ReadsItsMembersOnce()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
|
||||
await teams.LoadAsync(Token);
|
||||
|
||||
teams.NewTeamCommand.Execute(null);
|
||||
teams.NewTeamName = "Platform";
|
||||
teams.NewTeamSlug = "platform";
|
||||
|
||||
var gate = new TaskCompletionSource();
|
||||
|
||||
server.MemberReadGate = gate;
|
||||
|
||||
var create = teams.CreateTeamCommand.ExecuteAsync(null);
|
||||
|
||||
// Asserted while the read is still in flight: that is the only moment at which a second read
|
||||
// started by the selection handler is distinguishable from the reload's own.
|
||||
server.MemberReads.ShouldBe(1, "a reload reads the selected team's members once");
|
||||
|
||||
gate.SetResult();
|
||||
|
||||
await create;
|
||||
|
||||
teams.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Through the form rather than straight at the command, because the name is what the form is for: a
|
||||
/// vault used to be named after its team, which gave a team with three of them three vaults called the
|
||||
/// same thing.
|
||||
/// </remarks>
|
||||
private async Task CreateVaultAsync(TeamsViewModel teams, string name)
|
||||
{
|
||||
teams.NewVaultCommand.Execute(null);
|
||||
teams.NewVaultName = name;
|
||||
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
teams.IsCreatingVault.ShouldBeFalse(teams.Status);
|
||||
}
|
||||
|
||||
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
|
||||
{
|
||||
await teams.LoadAsync(Token);
|
||||
|
||||
teams.NewTeamCommand.Execute(null);
|
||||
teams.NewTeamName = name;
|
||||
teams.NewTeamSlug = slug;
|
||||
|
||||
await teams.CreateTeamCommand.ExecuteAsync(null);
|
||||
|
||||
teams.SelectedTeam.ShouldNotBeNull(teams.Status);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
|
||||
/// key that was really enrolled: the fake server publishes it into its key log during enrollment, and
|
||||
/// that entry is what the client verifies its own directory answer against.
|
||||
/// </remarks>
|
||||
private async Task UnlockedAsync()
|
||||
{
|
||||
await shell.StartAsync(Token);
|
||||
await shell.SignInCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
shell.ConfirmPassphrase = Passphrase;
|
||||
await shell.EnrollCommand.ExecuteAsync(null);
|
||||
|
||||
shell.RecoveryCodeWrittenDown = true;
|
||||
shell.ConfirmRecoveryCodeCommand.Execute(null);
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
using DodoSSH.Client.Session;
|
||||
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
|
||||
// see the csproj for why it is shared rather than reimplemented.
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Vaults, from the side that holds the keys: make one, add somebody, and wrap its key to them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The reason this suite exists rather than leaving sharing to the server's own tests is that the
|
||||
/// interesting half is not on the server. Adding a member is a row; <b>sharing is a decision the client
|
||||
/// makes about whether to trust a public key the server just handed it</b>, and that decision is what
|
||||
/// stands between an end-to-end encrypted vault and one the operator can read by answering a directory
|
||||
/// lookup with a key of their own.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the fake server keeps a real key log — chained with the same <c>KeyLogChain</c> the server uses —
|
||||
/// and can be told to corrupt it. A test that only ever saw a well-formed log would be checking that
|
||||
/// sharing works, not that verification does.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It was <c>TeamSharingTests</c>, and the screen it drives stopped being about teams: a vault is what
|
||||
/// gets made and named, and the membership list behind it is made with it. The team is still what the
|
||||
/// server authorises against, which is why the assertions about roles, hand-over and invitations are all
|
||||
/// still here — they are the same operations, reached through the vault they apply to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VaultSharingTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private readonly FakeVaultServer server = new();
|
||||
private readonly FakeSshConnectionFactory ssh = new();
|
||||
|
||||
private string directory = null!;
|
||||
private ClientCacheFactory caches = null!;
|
||||
private TerminalWorkspace workspace = null!;
|
||||
private VaultKnownHostStore knownHosts = null!;
|
||||
private FakeDeviceKeyStore deviceKeys = null!;
|
||||
private MainWindowViewModel shell = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
directory = Path.Combine(Path.GetTempPath(), $"dodossh-vaults-{Guid.CreateVersion7():N}");
|
||||
|
||||
var paths = new ClientPaths(directory);
|
||||
|
||||
caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
||||
knownHosts = new VaultKnownHostStore();
|
||||
deviceKeys = new FakeDeviceKeyStore();
|
||||
|
||||
workspace = new TerminalWorkspace(
|
||||
new InMemoryTerminalAssetProvider(
|
||||
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
ssh,
|
||||
TimeProvider.System);
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
paths,
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
deviceKeys,
|
||||
(_, _) => Task.FromResult<IVaultServer>(server),
|
||||
TimeProvider.System,
|
||||
NSubstitute.Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
caches.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A cache file the process has not finished releasing. The directory is under the temp path
|
||||
// and named per run, so leaving it costs a few kilobytes and never collides.
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The whole point of a shared vault, in one test. Note what the status line says after the add and
|
||||
/// before the share: adding somebody grants them nothing readable, and the interface has to say so
|
||||
/// rather than let a user believe the credential is already with their colleague.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CreatingAVaultAndSharingIt_WrapsTheKeyToTheOtherMember()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Members.Count.ShouldBe(2, vaults.Status);
|
||||
vaults.Status.ShouldContain("cannot read anything yet");
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
|
||||
await vaults.ShareVaultCommand.ExecuteAsync(null);
|
||||
|
||||
var vaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
server.IssuedGrants.ShouldContainKey((vaultId, colleague));
|
||||
vaults.Status.ShouldContain("Shared");
|
||||
|
||||
// The one thing verification cannot promise, said in the same breath as the success.
|
||||
vaults.Status.ShouldContain("fingerprint", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The test this whole design exists for. A server that wants to read a shared vault only has to
|
||||
/// answer one directory lookup with a key it holds the private half of — so the client reads the
|
||||
/// append-only key log, verifies its chain, and refuses to wrap anything unless the key it was
|
||||
/// offered is in there unchanged.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing may be sent. A refusal that still issued the grant, or that issued it on a retry, would be
|
||||
/// worse than no check at all, because the interface would have said it was verified.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATamperedKeyLog_StopsTheShareRatherThanWarningAboutIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "mallory@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
|
||||
server.CorruptKeyLog = true;
|
||||
|
||||
await vaults.ShareVaultCommand.ExecuteAsync(null);
|
||||
|
||||
server.IssuedGrants.ShouldBeEmpty();
|
||||
vaults.Status.ShouldContain("Did not share");
|
||||
vaults.Status.ShouldContain("key log");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A vault created here is usable here, without a relock. The key was generated in this process, so
|
||||
/// making the user lock and unlock to reach the vault they just made would be asking them to work
|
||||
/// around bookkeeping.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AVaultCreatedHere_IsImmediatelyReadableAndWritable()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vaultId = vaults.SelectedVault!.VaultId;
|
||||
var session = shell.Vault!.Session;
|
||||
|
||||
session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId);
|
||||
|
||||
// And it is offered as somewhere to file a new item, which is what makes it worth having.
|
||||
await shell.Vault.LoadAsync(Token);
|
||||
|
||||
shell.Vault.TargetVaults.Select(choice => choice.VaultId).ShouldContain(vaultId);
|
||||
shell.Vault.HasVaultChoice.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Making a vault makes exactly one membership list, and this is the assertion that the two-step create
|
||||
/// has not started leaking them: the screen no longer offers to make one on its own, so a second one
|
||||
/// per vault would be invisible in the interface and visible only to an operator.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CreatingAVault_MakesOneMembershipListWithTheCallerAsItsOwner()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
server.TeamCreates.ShouldBe(1);
|
||||
|
||||
var row = vaults.Vaults.Single(
|
||||
vault => string.Equals(vault.Name, "Platform secrets", StringComparison.Ordinal));
|
||||
|
||||
row.IsShared.ShouldBeTrue("a vault made here is one other people can be added to");
|
||||
row.IsOwned.ShouldBeTrue(vaults.Status);
|
||||
row.SharedWithOtherVaults.ShouldBe(0, "it was made with a membership list of its own");
|
||||
|
||||
vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The personal vault is in the list, is marked as the one thing it is, and offers nothing to share:
|
||||
/// the server refuses a grant on one outright, so a screen that let somebody try would be sending them
|
||||
/// at a refusal.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePersonalVault_IsListedAndCannotBeSharedWithAnybody()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
var personal = vaults.Vaults.ShouldHaveSingleItem();
|
||||
|
||||
personal.IsPersonal.ShouldBeTrue();
|
||||
personal.IsShared.ShouldBeFalse();
|
||||
personal.RoleLabel.ShouldBe("PERSONAL");
|
||||
|
||||
vaults.SelectedVault = personal;
|
||||
vaults.SelectedIsShared.ShouldBeFalse();
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Members.ShouldBeEmpty();
|
||||
vaults.Status.ShouldContain("cannot be shared");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Filing into a shared vault has to be chosen and has to stick. The bug this guards is the obvious
|
||||
/// one: an editor that read the picker at save time rather than at open time, so changing the picker
|
||||
/// with a half-typed host on screen would move it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AHostFiledIntoASharedVault_StaysThere()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.SelectedTargetVault =
|
||||
vault.TargetVaults.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
// Moved back after the editor opened. The host must still land in the shared vault: the keychain
|
||||
// screen's picker seeds the editor's and stops mattering from there.
|
||||
vault.SelectedTargetVault =
|
||||
vault.TargetVaults.First(choice => choice.VaultId != sharedVaultId);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var row = vault.Hosts.Single(
|
||||
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
|
||||
row.VaultId.ShouldBe(sharedVaultId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The picker the host editor grew, and the thing it is for: choosing at the moment a host is created,
|
||||
/// on the form the host is being typed into, rather than through a standing preference on another
|
||||
/// screen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is asserted from the editor's own selection rather than the keychain screen's, because the two
|
||||
/// are deliberately separate — moving one must not move the other.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostEditorChoosesItsOwnVault_WithoutMovingTheKeychainScreensPicker()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
vault.ShowsEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between");
|
||||
|
||||
var personal = vault.SelectedTargetVault!;
|
||||
|
||||
vault.EditorSelectedVault =
|
||||
vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Hosts
|
||||
.Single(host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal))
|
||||
.VaultId
|
||||
.ShouldBe(sharedVaultId);
|
||||
|
||||
vault.SelectedTargetVault.ShouldBe(
|
||||
personal, "the editor's picker is the host's, not the screen's standing preference");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An existing host is not offered the picker at all. Moving an item between vaults is a delete and a
|
||||
/// retype — they are encrypted under different keys — so a control that appeared to offer it would be
|
||||
/// offering something no layer below can do.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task EditingAnExistingHost_DoesNotOfferToMoveItBetweenVaults()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single(
|
||||
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.IsEditing.ShouldBeTrue(vault.Status);
|
||||
vault.ShowsEditorVaultChoice.ShouldBeFalse("an item cannot be moved between vaults");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The mirror image of the host test above, and it goes the other way on purpose. A host filed into a
|
||||
/// shared vault has to stay there, because hosts are read across every readable vault and so come back.
|
||||
/// Tags are not — the editable list is the active vault's alone, like groups and buckets — so a tag
|
||||
/// filed anywhere else would be created, pushed, reported as added and then invisible, with nothing on
|
||||
/// the keychain screen able to rename or delete it and no active-vault switcher to go and find it with.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATagIgnoresTheTargetPicker_BecauseItsListOnlyEverShowsOneVault()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.HasVaultChoice.ShouldBeTrue("this test is meaningless with one vault");
|
||||
|
||||
vault.SelectedTargetVault = vault.TargetVaults.Single(
|
||||
choice => choice.VaultId == sharedVaultId);
|
||||
|
||||
vault.NewTagCommand.Execute(null);
|
||||
vault.TagEditorLabel = "eu-west-1";
|
||||
await vault.SaveTagCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Tags.ShouldHaveSingleItem().Label
|
||||
.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". Asserted after a share rather than before,
|
||||
/// because an empty list proves nothing about whether the call was made.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SelectingAVault_ListsWhoHoldsAKeyToIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
|
||||
await vaults.ShareVaultCommand.ExecuteAsync(null);
|
||||
|
||||
var holder = vaults.Grants.ShouldHaveSingleItem();
|
||||
|
||||
holder.UserId.ShouldBe(colleague);
|
||||
holder.IsLive.ShouldBeTrue(vaults.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 vaults = shell.Vaults;
|
||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
|
||||
await vaults.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Admin);
|
||||
|
||||
vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("ADMIN");
|
||||
vaults.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 vaults = shell.Vaults;
|
||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
|
||||
await vaults.ChangeRoleCommand.ExecuteAsync(TeamMemberRole.Owner);
|
||||
|
||||
vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("MEMBER");
|
||||
vaults.Status.ShouldContain("HAND OVER");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Both halves, because a transfer that only promoted the recipient would leave the vault 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 ids are carried — and carrying them on the selection
|
||||
/// instead is how a confirmation ends up applied to whatever was clicked last.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task HandingOverAVault_MakesThemTheOwnerAndTheCallerAnAdmin()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||
|
||||
vaults.HandOverCommand.Execute(null);
|
||||
|
||||
vaults.IsConfirming.ShouldBeTrue("the hand-over has to be answered, not just pressed");
|
||||
vaults.ShowsVaultActions.ShouldBeFalse("the buttons that armed it are replaced, not left live");
|
||||
|
||||
await vaults.ConfirmActionCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Members.Single(member => member.UserId == colleague).Role.ShouldBe("OWNER");
|
||||
vaults.Members.Single(member => member.IsSelf).Role.ShouldBe("ADMIN");
|
||||
vaults.IsConfirming.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Renaming reaches the rest of the shell, which is the half a client can get wrong quietly: the name
|
||||
/// is drawn on the badge of every host card in a session holding more than one vault, in the
|
||||
/// file-this-into picker, and in the tab strip's menu.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RenamingAVault_ReachesTheKeychainScreensPickerToo()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
vaults.RenameVaultCommand.Execute(null);
|
||||
vaults.EditVaultName = "Platform";
|
||||
|
||||
await vaults.SaveVaultNameCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Vaults.Single(vault => vault.VaultId == vaultId).Name.ShouldBe("Platform");
|
||||
vaults.Status.ShouldContain("re-encrypted");
|
||||
|
||||
await shell.Vault!.LoadAsync(Token);
|
||||
|
||||
shell.Vault.TargetVaults
|
||||
.Single(choice => choice.VaultId == vaultId)
|
||||
.Name
|
||||
.ShouldBe("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 vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "newcomer@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
|
||||
|
||||
var invitation = vaults.Invitations.ShouldHaveSingleItem();
|
||||
|
||||
invitation.Email.ShouldBe("newcomer@example.com");
|
||||
invitation.IsPending.ShouldBeTrue();
|
||||
invitation.State.ShouldContain("Nothing was sent");
|
||||
|
||||
vaults.Status.ShouldContain("cannot send mail");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The regression this whole path was rewritten for. An account exists from its owner's first
|
||||
/// authenticated request and publishes no key until they choose a passphrase on their own machine,
|
||||
/// and the directory omits it for that entire window — an entry exists to be wrapped to, and this
|
||||
/// one has nothing to wrap. Reading that silence as "there is no such account" meant ADD quietly
|
||||
/// issued an invitation instead: the members list did not change, the screen said they had no
|
||||
/// account here, and they only actually joined on the next hourly sweep.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the assertion is that they are a <em>member</em>, not an invitation, and that the row says
|
||||
/// what is true of them — no key, so nothing can be shared with them yet.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AddingAnAccountThatHasNotEnrolled_MakesThemAMemberWithNoKey()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
var colleague = server.AddUnenrolledAccount("carol@example.com", "Carol Example");
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "carol@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Invitations.ShouldBeEmpty("they have an account here, so there is nothing to invite");
|
||||
|
||||
vaults.Members.Count.ShouldBe(2, vaults.Status);
|
||||
|
||||
var member = vaults.Members.Single(row => row.UserId == colleague);
|
||||
|
||||
member.Email.ShouldBe("carol@example.com");
|
||||
|
||||
// The label the user asked to see, and the reason SHARE KEY is not the next step.
|
||||
member.KeyState.ShouldContain("no key yet");
|
||||
|
||||
vaults.Status.ShouldContain("Added");
|
||||
vaults.Status.ShouldContain("no key yet");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half of the pair above: an address with no account at all still falls through to an
|
||||
/// invitation. It is the server that decides which, so this proves the fall-through survived being
|
||||
/// moved behind it rather than being replaced by an error.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AddingAnAddressWithNoAccount_StillInvitesRatherThanFailing()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "stranger@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Members.ShouldHaveSingleItem("nobody has joined — they have only been invited");
|
||||
vaults.Invitations.ShouldHaveSingleItem().Email.ShouldBe("stranger@example.com");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A withdrawn invitation stays on the list saying it was withdrawn, rather than vanishing. One that
|
||||
/// disappeared would read as never having been sent, which is the same thing the screen looks like
|
||||
/// before anybody does anything.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task WithdrawingAnInvitation_LeavesItListedAsWithdrawn()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
vaults.InviteEmail = "newcomer@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.SelectedInvitation = vaults.Invitations.ShouldHaveSingleItem();
|
||||
|
||||
await vaults.RevokeInvitationCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.Invitations.ShouldHaveSingleItem().State.ShouldBe("withdrawn");
|
||||
vaults.Status.ShouldContain("Withdrew the invitation");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A reload rebuilds the vault list and reselects, so a reload that changed the selection — creating
|
||||
/// the first shared vault is exactly that — used to leave two reads of the same membership list in
|
||||
/// flight: the one the reload awaits, and one the selection handler started on its own. Both clear the
|
||||
/// member list and then both append to it, so every member was drawn twice. On a vault nobody has been
|
||||
/// added to yet, whose only member is its owner, that read as the owner being in it twice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Counted rather than inferred from the list, and the gate is why: against a fake that answers from
|
||||
/// memory each read finishes before the next begins, so the duplicate never appears and the bug
|
||||
/// survives the test. Holding the read open is what makes this behave like a server.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CreatingAVault_ReadsItsMembersOnce()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
vaults.NewVaultCommand.Execute(null);
|
||||
vaults.NewVaultName = "Platform secrets";
|
||||
|
||||
var gate = new TaskCompletionSource();
|
||||
|
||||
server.MemberReadGate = gate;
|
||||
|
||||
var create = vaults.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
// Asserted while the read is still in flight: that is the only moment at which a second read
|
||||
// started by the selection handler is distinguishable from the reload's own.
|
||||
server.MemberReads.ShouldBe(1, "a reload reads the selected vault's members once");
|
||||
|
||||
gate.SetResult();
|
||||
|
||||
await create;
|
||||
|
||||
vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Through the form rather than straight at the command, because the name is what the form is for —
|
||||
/// and because the form is now the only way in: there is no separate "make a team" step behind it.
|
||||
/// </remarks>
|
||||
private static async Task CreateVaultAsync(VaultsViewModel vaults, string name)
|
||||
{
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
vaults.NewVaultCommand.Execute(null);
|
||||
vaults.NewVaultName = name;
|
||||
|
||||
await vaults.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);
|
||||
vaults.SelectedVault.ShouldNotBeNull(vaults.Status);
|
||||
vaults.SelectedVault!.IsShared.ShouldBeTrue(vaults.Status);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
|
||||
/// key that was really enrolled: the fake server publishes it into its key log during enrollment, and
|
||||
/// that entry is what the client verifies its own directory answer against.
|
||||
/// </remarks>
|
||||
private async Task UnlockedAsync()
|
||||
{
|
||||
await shell.StartAsync(Token);
|
||||
await shell.SignInCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
shell.ConfirmPassphrase = Passphrase;
|
||||
await shell.EnrollCommand.ExecuteAsync(null);
|
||||
|
||||
shell.RecoveryCodeWrittenDown = true;
|
||||
shell.ConfirmRecoveryCodeCommand.Execute(null);
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -90,28 +91,31 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
|
||||
|
||||
/// <remarks>
|
||||
/// The whole feature in one test. A name is all that is asked for, and what comes back is a vault this
|
||||
/// machine can already write to inside a team this account owns — which is what makes the rest of the
|
||||
/// screen, members and roles and key holders, apply to it.
|
||||
/// machine can already write to, with a membership list this account owns — which is what makes the
|
||||
/// rest of the screen, members and roles and key holders, apply to it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CreatingAVaultByNameAlone_MakesATeamForItAndOwnsIt()
|
||||
public async Task CreatingAVaultByNameAlone_MakesTheMembershipListForItAndOwnsIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await teams.LoadAsync(Token);
|
||||
await vaults.LoadAsync(Token);
|
||||
await CreateVaultAsync("Platform secrets");
|
||||
|
||||
var team = teams.Teams.ShouldHaveSingleItem();
|
||||
// Read from the server rather than off the screen: the membership list behind a vault is not a
|
||||
// thing this screen shows any more, and that is exactly why it is worth asserting on directly.
|
||||
var team = (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();
|
||||
|
||||
team.Name.ShouldBe("Platform secrets");
|
||||
team.Slug.ShouldBe("platform-secrets", "the slug is derived rather than asked for");
|
||||
team.Role.ShouldBe("OWNER");
|
||||
team.Role.ShouldBe(TeamMemberRole.Owner);
|
||||
|
||||
var vault = teams.Vaults.ShouldHaveSingleItem();
|
||||
var vault = vaults.Vaults.Single(
|
||||
row => string.Equals(row.Name, "Platform secrets", StringComparison.Ordinal));
|
||||
|
||||
vault.Name.ShouldBe("Platform secrets");
|
||||
vault.IsOwned.ShouldBeTrue(vaults.Status);
|
||||
shell.Vault!.Session.ReadableVaults
|
||||
.Select(row => row.VaultId)
|
||||
.ShouldContain(vault.VaultId, "a vault made here is usable here, without a relock");
|
||||
@@ -122,81 +126,109 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
|
||||
/// next thing anybody making a shared vault wants is the people, and the people are here.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CreatingAVaultByNameAlone_LeavesTheNewVaultSelectedOnTheTeamsScreen()
|
||||
public async Task CreatingAVaultByNameAlone_LeavesTheNewVaultSelectedOnTheVaultsScreen()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await teams.LoadAsync(Token);
|
||||
await vaults.LoadAsync(Token);
|
||||
await CreateVaultAsync("Platform secrets");
|
||||
|
||||
teams.SelectedTeam.ShouldNotBeNull(teams.Status);
|
||||
teams.SelectedTeam.Name.ShouldBe("Platform secrets");
|
||||
teams.SelectedVault.ShouldNotBeNull(teams.Status);
|
||||
teams.SelectedVault.Name.ShouldBe("Platform secrets");
|
||||
vaults.SelectedVault.ShouldNotBeNull(vaults.Status);
|
||||
vaults.SelectedVault.Name.ShouldBe("Platform secrets");
|
||||
vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The failure between the two calls. The team is real and stays — a client that archived it because a
|
||||
/// later step failed is a client that will one day archive a team somebody has just been added to — so
|
||||
/// the sentence has to carry the whole state rather than "creating the vault failed".
|
||||
/// The failure between the two calls. The membership list is real and is kept for the retry — the
|
||||
/// sentence has to carry the whole state rather than "creating the vault failed", because pressing
|
||||
/// CREATE again is what finishes the job and cancelling is what undoes it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AVaultCreateThatFailsAfterTheTeam_KeepsTheTeamAndSaysSo()
|
||||
public async Task AVaultCreateThatFailsAfterTheMembershipList_KeepsItAndSaysSo()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await teams.LoadAsync(Token);
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
server.VaultCreateFailures = 1;
|
||||
|
||||
teams.NewVaultInItsOwnTeamCommand.Execute(null);
|
||||
teams.NewVaultName = "Platform secrets";
|
||||
vaults.NewVaultCommand.Execute(null);
|
||||
vaults.NewVaultName = "Platform secrets";
|
||||
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
await vaults.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
teams.Teams.ShouldHaveSingleItem().Name.ShouldBe("Platform secrets");
|
||||
teams.Vaults.ShouldBeEmpty();
|
||||
(await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();
|
||||
vaults.Vaults.ShouldNotContain(
|
||||
row => string.Equals(row.Name, "Platform secrets", StringComparison.Ordinal));
|
||||
|
||||
teams.IsCreatingVault.ShouldBeTrue("the form stays open so CREATE can be pressed again");
|
||||
teams.NewVaultName.ShouldBe("Platform secrets", "and what was typed is still in it");
|
||||
vaults.IsCreatingVault.ShouldBeTrue("the form stays open so CREATE can be pressed again");
|
||||
vaults.NewVaultName.ShouldBe("Platform secrets", "and what was typed is still in it");
|
||||
|
||||
teams.Status.ShouldContain("was created, but its vault was not");
|
||||
teams.Status.ShouldContain("Press CREATE again");
|
||||
vaults.Status.ShouldContain("was not created");
|
||||
vaults.Status.ShouldContain("Press CREATE again");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The retry, and the reason the team id is generated once and held rather than per attempt. A second
|
||||
/// team would leave somebody with two identically named ones and no way to tell which is which.
|
||||
/// The retry, and the reason the id is generated once and held rather than per attempt. A second
|
||||
/// membership list would be one nothing on this screen could show and nobody could remove.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RetryingAfterTheVaultCreateFailed_ReusesTheTeamRatherThanMakingASecond()
|
||||
public async Task RetryingAfterTheVaultCreateFailed_ReusesTheMembershipListRatherThanMakingASecond()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await teams.LoadAsync(Token);
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
server.VaultCreateFailures = 1;
|
||||
|
||||
teams.NewVaultInItsOwnTeamCommand.Execute(null);
|
||||
teams.NewVaultName = "Platform secrets";
|
||||
vaults.NewVaultCommand.Execute(null);
|
||||
vaults.NewVaultName = "Platform secrets";
|
||||
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
await vaults.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
var teamId = teams.Teams.ShouldHaveSingleItem().TeamId;
|
||||
var teamId = (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem().TeamId;
|
||||
|
||||
// Pressed again on the form that is still open, which is exactly what the message tells the user
|
||||
// to do.
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
await vaults.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
teams.Teams.ShouldHaveSingleItem().TeamId.ShouldBe(teamId);
|
||||
teams.Vaults.ShouldHaveSingleItem().Name.ShouldBe("Platform secrets");
|
||||
teams.IsCreatingVault.ShouldBeFalse(teams.Status);
|
||||
(await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem().TeamId.ShouldBe(teamId);
|
||||
vaults.Vaults.ShouldContain(
|
||||
row => string.Equals(row.Name, "Platform secrets", StringComparison.Ordinal));
|
||||
vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Cancelling takes the half-made membership list with it, which is the one place this application
|
||||
/// tidies up on the user's behalf. The reason is that nothing on the screen can reach it: a membership
|
||||
/// list with no vault has no row, so leaving it would leave something the user can neither see nor
|
||||
/// remove.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CancellingAfterTheVaultCreateFailed_TakesTheMembershipListWithIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
server.VaultCreateFailures = 1;
|
||||
|
||||
vaults.NewVaultCommand.Execute(null);
|
||||
vaults.NewVaultName = "Platform secrets";
|
||||
|
||||
await vaults.CreateVaultCommand.ExecuteAsync(null);
|
||||
await vaults.CancelNewVaultCommand.ExecuteAsync(null);
|
||||
|
||||
(await server.Teams.ListTeamsAsync(Token))
|
||||
.ShouldBeEmpty("the membership list nobody was shown is not left behind");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -209,15 +241,15 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await teams.LoadAsync(Token);
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
server.TakenSlugs.Add("platform-secrets");
|
||||
|
||||
await CreateVaultAsync("Platform secrets");
|
||||
|
||||
var team = teams.Teams.ShouldHaveSingleItem();
|
||||
var team = (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();
|
||||
|
||||
team.Name.ShouldBe("Platform secrets", "the name is what the user typed");
|
||||
team.Slug.ShouldStartWith("platform-secrets-");
|
||||
@@ -226,20 +258,20 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
|
||||
|
||||
/// <remarks>
|
||||
/// A name written in a script with no a-z or 0-9 in it leaves nothing to slugify. It still has to be a
|
||||
/// vault a person can make, so the fallback is the team's own id rather than a refusal pointing at a
|
||||
/// field that does not exist.
|
||||
/// vault a person can make, so the fallback is an id rather than a refusal pointing at a field that
|
||||
/// does not exist.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AVaultNameWithNothingSluggableInIt_StillGetsAUsableSlug()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var teams = shell.Teams;
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await teams.LoadAsync(Token);
|
||||
await vaults.LoadAsync(Token);
|
||||
await CreateVaultAsync("διαχείριση");
|
||||
|
||||
var team = teams.Teams.ShouldHaveSingleItem();
|
||||
var team = (await server.Teams.ListTeamsAsync(Token)).ShouldHaveSingleItem();
|
||||
|
||||
team.Name.ShouldBe("διαχείριση");
|
||||
team.Slug.ShouldStartWith("vault-");
|
||||
@@ -455,7 +487,9 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
|
||||
shell.HasVaultSwitches.ShouldBeTrue();
|
||||
shell.VaultToggles.Count.ShouldBe(2);
|
||||
shell.VaultToggles[0].IsPersonal.ShouldBeTrue();
|
||||
shell.VaultToggles[1].Display.ShouldBe("Platform secrets · TEAM");
|
||||
// SHARED rather than TEAM: a team is no longer something the person reading this menu has been
|
||||
// shown, so the word names what the switch is actually about.
|
||||
shell.VaultToggles[1].Display.ShouldBe("Platform secrets · SHARED");
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
@@ -475,22 +509,22 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
|
||||
/// <summary>Names a vault, from the form the tab strip's menu opens.</summary>
|
||||
private async Task<Guid> CreateVaultAsync(string name)
|
||||
{
|
||||
var teams = shell.Teams;
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
teams.NewVaultInItsOwnTeamCommand.Execute(null);
|
||||
teams.NewVaultName = name;
|
||||
vaults.NewVaultCommand.Execute(null);
|
||||
vaults.NewVaultName = name;
|
||||
|
||||
await teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
await vaults.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
teams.IsCreatingVault.ShouldBeFalse(teams.Status);
|
||||
vaults.IsCreatingVault.ShouldBeFalse(vaults.Status);
|
||||
|
||||
return teams.Vaults.Single(row => string.Equals(row.Name, name, StringComparison.Ordinal))
|
||||
return vaults.Vaults.Single(row => string.Equals(row.Name, name, StringComparison.Ordinal))
|
||||
.VaultId;
|
||||
}
|
||||
|
||||
private async Task<Guid> VaultWithAHostAsync(string vaultName, string hostLabel)
|
||||
{
|
||||
await shell.Teams.LoadAsync(Token);
|
||||
await shell.Vaults.LoadAsync(Token);
|
||||
|
||||
var vaultId = await CreateVaultAsync(vaultName);
|
||||
|
||||
@@ -501,7 +535,7 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
|
||||
|
||||
private async Task<Guid> VaultWithAKeyAsync(string vaultName, string keyLabel)
|
||||
{
|
||||
await shell.Teams.LoadAsync(Token);
|
||||
await shell.Vaults.LoadAsync(Token);
|
||||
|
||||
var vaultId = await CreateVaultAsync(vaultName);
|
||||
var vault = shell.Vault!;
|
||||
|
||||
Reference in New Issue
Block a user