Public Access
Three things a user reported, one of which was a real bug and one of which was
not the bug it looked like.
**A vault shared with somebody never reached their machine.** The grant was
correct at both ends: the sharing client verified the recipient's key against the
key log and wrapped every generation to it, the server stored it, and /me would
have returned it. Nothing asked. VaultSession.RefreshVaultsAsync — the method
whose own summary says it is "called after a share and on a periodic pass" — had
no caller anywhere in the application, so the vault list was whatever the last
browser sign-in cached. A restart did not help: an offline unlock reads that same
cache. The vault appeared only if the recipient happened to sign in through the
browser again, which is why this looked like sharing being broken rather than
like a list that was never re-read.
So every synchronisation pass now re-reads it, before it syncs. SyncOnceAsync
takes the whole server rather than its sync half for that reason, and the order
matters: a vault admitted by the refresh is one that same pass then pulls, where
the other order would show a newly shared vault as an empty one until the minute
after. The shell is told only when the set actually changed — it rebuilds the tab
strip's vault menu from the session's list, and doing that on every quiet pass
would rebuild a menu once a minute for nothing.
The test needed the fake server to be able to do something no test here had
needed before: hand this account a vault it did not make. ShareVaultWithMe wraps
a real key to the encryption key this account enrolled, so the keyring opens it
exactly as it opens a real colleague's — a helper that filled the field with
bytes would let a vault appear in the list and never prove it could be read.
**Adding an S3 bucket on the desktop works, and could not be found.** The report
was that it is not possible; driving the real XAML headlessly says otherwise —
Keychain, + BUCKET, and the editor saves. What is true is that S3 is where
somebody goes looking, and from there SELECT BUCKET opened a combo box with
nothing in it and no sentence anywhere saying that a bucket is a keychain item.
From where the user was standing that is indistinguishable from an application
with no way to add one.
The empty state now says what a bucket is and offers a button that lands on the
keychain with the editor already open — navigating to the screen and leaving
+ BUCKET to be found among five buttons would be most of the same problem. The
phone gets the sentence and no button: its keychain screen reads and deletes and
edits nothing, so there is no editor to send anybody to, and naming the machine
that has one beats an empty control that reads as a screen still loading.
The keychain screen's layout test grew the two categories it never covered.
Tags and buckets arrived after it was written, and the header strip it measures
is one that has overflowed twice before.
**A vault can now be deleted.** DELETE /api/v1/vaults/{id}, gated on Admin —
the line the rename already drew, for a stronger version of its reason, since
this takes the vault from everybody in it at once. The row is soft-deleted and
every grant to it withdrawn in one write; VaultAccessService filters on the stamp
at both ends, so from that moment the vault is absent from every member's /me and
every call naming it answers 404. Their clients notice on the pass described
above.
The team behind it is archived when it owned nothing else, which is the mirror of
renaming it: a vault made from the vaults screen gets a team named after it that
nobody was ever shown, and leaving that behind would leave a membership list no
screen has a row for. That is a second call rather than one transaction —
archiving is TeamService's, it refuses while a team owns vaults, and it can only
tell that this one no longer does once the deletion is committed. A crash between
the two leaves an empty team: invisible, archivable afterwards, harmless, and a
better failure than a vault that could not be deleted because tidying up after it
did not work.
Two refusals worth stating. The personal vault cannot be deleted at either end:
it is created by enrollment, everything filed nowhere else lives in it, and no
call would make another. And the items are kept — ciphertext behind a vault
nothing will resolve, so deleting them buys no confidentiality while destroying
what an operator undoing a mistake would need.
The client drops the key from the keyring and the row from the cache rather than
waiting for a refresh, so the list is right immediately; the items stay, as they
stay for a vault whose grant was withdrawn, because a copy is on every other
member's machine too and removing these rows would be the client pretending to a
reach it does not have. The confirmation says that out loud before it is
answered. It is the one sentence this screen must not leave implied: deletion is
no more retroactive than revocation is. See ADR 0001.
Desktop only, deliberately. The Android vaults screen offers no rename and no
hand-over either, so adding delete alone there would be the one destructive vault
operation on a screen with no other.
Three places asserted that a vault can never be deleted — TeamService's refusal
message, the TeamNotEmpty problem code, and ADR 0009 — and each now names the
route instead.
1006 lines
39 KiB
C#
1006 lines
39 KiB
C#
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. Adding somebody wraps the vault to them, so the
|
|
/// status line names what they were given rather than what is still owed — and the grant is on the
|
|
/// server before the add has finished reporting.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AddingSomebody_WrapsTheVaultToThemStraightAway()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
var vaults = shell.Vaults;
|
|
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
|
|
|
await CreateVaultAsync(vaults, "Platform secrets");
|
|
|
|
var vaultId = vaults.SelectedVault!.VaultId;
|
|
|
|
vaults.InviteEmail = "bob@example.com";
|
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
|
|
|
vaults.Members.Count.ShouldBe(2, vaults.Status);
|
|
|
|
server.IssuedGrants.ShouldContainKey(
|
|
(vaultId, colleague),
|
|
"adding somebody to a vault is what shares it with them");
|
|
|
|
vaults.Status.ShouldContain("Platform secrets");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The manual path still works and is still worth having: a vault whose key this machine did not
|
|
/// hold when somebody was added is shared by pressing the button once it does. Re-wrapping to
|
|
/// somebody who already holds the key is the same call, and the server replaces the row rather than
|
|
/// adding a second one.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task SharingAVaultByHand_WrapsTheKeyAndSaysWhatItCannotPromise()
|
|
{
|
|
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 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 other half of the same idea. Removing somebody withdraws their grants — which only blocks
|
|
/// future reads — so the vault is rotated in the same breath and the new key goes to the people who
|
|
/// are left. From that moment nothing written is readable to the person who went.
|
|
/// </para>
|
|
/// <para>
|
|
/// The remaining member is given the earlier generation as well as the new one, which is what keeps
|
|
/// the vault's existing items readable to them: a rotation re-keys the vault, not its contents.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task RemovingSomebody_RotatesTheVaultAndHandsTheNewKeyToWhoIsLeft()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
var vaults = shell.Vaults;
|
|
var leaving = server.AddAccount("bob@example.com", "Bob Example");
|
|
var staying = server.AddAccount("carol@example.com", "Carol Example");
|
|
|
|
await CreateVaultAsync(vaults, "Platform secrets");
|
|
|
|
var vaultId = vaults.SelectedVault!.VaultId;
|
|
|
|
foreach (var address in (string[])["bob@example.com", "carol@example.com"])
|
|
{
|
|
vaults.InviteEmail = address;
|
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
vaults.Members.Count.ShouldBe(3, vaults.Status);
|
|
|
|
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == leaving);
|
|
|
|
await vaults.RemoveMemberCommand.ExecuteAsync(null);
|
|
|
|
vaults.Status.ShouldContain("Rotated", customMessage: vaults.Status);
|
|
vaults.Status.ShouldContain("Platform secrets");
|
|
|
|
// The last act of a rotation is moving what is already stored onto the new key. Proven by the
|
|
// bytes in DodoSSH.Client.Sync.Tests; what this asserts is that the shell asks for it at all,
|
|
// and says which of the two guarantees the user has ended up with.
|
|
vaults.Status.ShouldContain("re-sealed under the new key", customMessage: vaults.Status);
|
|
|
|
// Gone entirely, at every generation. A revocation that left the history behind would leave them
|
|
// able to read everything written before they went, from a copy of the ciphertext.
|
|
server.GenerationsGranted(vaultId, leaving).ShouldBeEmpty();
|
|
|
|
// And the member who stayed holds both: the new key for what comes next, the old one for what
|
|
// is already stored under it.
|
|
server.GenerationsGranted(vaultId, staying).ShouldBe([1u, 2u]);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Somebody added after a rotation is given every generation the sharing machine holds, not only the
|
|
/// newest. A vault shared as one key would open to a list of items that will not decrypt, which
|
|
/// reads as corruption rather than as the missing grant it is.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AddingSomebodyToARotatedVault_HandsThemItsHistoryAsWell()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
var vaults = shell.Vaults;
|
|
var first = server.AddAccount("bob@example.com", "Bob Example");
|
|
var second = server.AddAccount("carol@example.com", "Carol Example");
|
|
|
|
await CreateVaultAsync(vaults, "Platform secrets");
|
|
|
|
var vaultId = vaults.SelectedVault!.VaultId;
|
|
|
|
vaults.InviteEmail = "bob@example.com";
|
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
|
|
|
// Removing them is what rotates the vault, so the next person to be added arrives at a vault
|
|
// with a history rather than one that has only ever had a single key.
|
|
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == first);
|
|
await vaults.RemoveMemberCommand.ExecuteAsync(null);
|
|
|
|
vaults.InviteEmail = "carol@example.com";
|
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
|
|
|
server.GenerationsGranted(vaultId, second).ShouldBe([1u, 2u], vaults.Status);
|
|
}
|
|
|
|
/// <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");
|
|
|
|
// Before the add, because the add now shares. Both routes to a wrap have to refuse, and a test
|
|
// that corrupted the log afterwards would be asserting about the second one only.
|
|
server.CorruptKeyLog = true;
|
|
|
|
vaults.InviteEmail = "mallory@example.com";
|
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
|
|
|
var vaultId = vaults.SelectedVault!.VaultId;
|
|
|
|
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
|
|
vaults.Status.ShouldContain("Could not share");
|
|
vaults.Status.ShouldContain("key log");
|
|
|
|
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
|
|
|
await vaults.ShareVaultCommand.ExecuteAsync(null);
|
|
|
|
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
|
|
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>
|
|
/// <para>
|
|
/// Sharing from the receiving end, which is the half that has to happen on somebody else's machine and
|
|
/// the half that was missing. A vault wrapped to this account appears in <c>/me</c> and nowhere else —
|
|
/// there is no push channel — so a client that never re-read that list showed nothing, indefinitely,
|
|
/// while the server and the grant were both perfectly correct.
|
|
/// </para>
|
|
/// <para>
|
|
/// Readable rather than merely listed, because those are two different failures with the same symptom:
|
|
/// a row that cannot be opened is a vault whose key never arrived, and this asserts the wrap was taken
|
|
/// into the keyring. The switch is asserted too — it is built by the shell rather than by the vault, so
|
|
/// it is the one thing a pass could admit a vault without redrawing.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AVaultSomebodyElseShared_ArrivesOnTheNextSynchronisation()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
|
var vaultId = server.ShareVaultWithMe("Platform secrets", colleague);
|
|
|
|
var vault = shell.Vault.ShouldNotBeNull();
|
|
|
|
await vault.SyncCommand.ExecuteAsync(null);
|
|
|
|
vault.Session.ReadableVaults.ShouldContain(
|
|
row => row.VaultId == vaultId,
|
|
"a vault shared with this account arrives on a synchronisation pass, with its key");
|
|
|
|
shell.VaultToggles.ShouldContain(
|
|
toggle => toggle.VaultId == vaultId,
|
|
"the tab strip's vault menu is built by the shell and has to be told");
|
|
|
|
await shell.Vaults.LoadAsync(Token);
|
|
|
|
var row = shell.Vaults.Vaults.Single(vault => vault.VaultId == vaultId);
|
|
|
|
row.IsReadable.ShouldBeTrue(shell.Vaults.Status);
|
|
row.IsOwned.ShouldBeFalse("somebody else made this one");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Deleting a shared vault, which is an admin's operation and the only one on this screen that cannot
|
|
/// be undone. It has to take three things with it: the vault, everybody's key to it — including the
|
|
/// people it was shared with — and this machine's own copy of the row, so the list is right before the
|
|
/// next refresh rather than after it.
|
|
/// </para>
|
|
/// <para>
|
|
/// The status line is asserted for what it says about the limit rather than for its wording. A message
|
|
/// implying that deletion reaches a colleague's laptop would be the one dishonest sentence this screen
|
|
/// could print; see ADR 0001.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DeletingAVault_TakesItAndEverybodysKeyToIt()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
var vaults = shell.Vaults;
|
|
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
|
|
|
await CreateVaultAsync(vaults, "Platform secrets");
|
|
|
|
var vaultId = vaults.SelectedVault!.VaultId;
|
|
|
|
vaults.InviteEmail = "bob@example.com";
|
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
|
|
|
server.IssuedGrants.ShouldContainKey((vaultId, colleague));
|
|
|
|
vaults.CanDeleteSelected.ShouldBeTrue("an admin may delete a shared vault");
|
|
|
|
vaults.DeleteVaultCommand.Execute(null);
|
|
|
|
var question = vaults.PendingAction.ShouldNotBeNull("deletion is never carried out unasked");
|
|
|
|
question.Consequence.ShouldContain(
|
|
"already synced", Case.Insensitive, "the one limit this must not leave implied");
|
|
|
|
await vaults.ConfirmActionCommand.ExecuteAsync(null);
|
|
|
|
vaults.Vaults.ShouldNotContain(row => row.VaultId == vaultId, vaults.Status);
|
|
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
|
|
|
|
shell.Vault!.Session.Vaults.ShouldNotContain(
|
|
row => row.VaultId == vaultId,
|
|
"the machine that deleted it does not wait for a refresh to stop listing it");
|
|
|
|
shell.VaultToggles.ShouldNotContain(toggle => toggle.VaultId == vaultId);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The one vault deletion cannot reach, refused by the screen rather than by the server: everything
|
|
/// filed nowhere else lives in it and nothing can make another, so the button is not offered and the
|
|
/// command says why if something reaches it anyway.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ThePersonalVault_CannotBeDeleted()
|
|
{
|
|
await UnlockedAsync();
|
|
|
|
var vaults = shell.Vaults;
|
|
|
|
await vaults.LoadAsync(Token);
|
|
|
|
vaults.SelectedVault = vaults.Vaults.Single(vault => vault.IsPersonal);
|
|
|
|
vaults.CanDeleteSelected.ShouldBeFalse();
|
|
|
|
vaults.DeleteVaultCommand.Execute(null);
|
|
|
|
vaults.PendingAction.ShouldBeNull("nothing was armed");
|
|
vaults.Status.ShouldContain("cannot be deleted");
|
|
vaults.Vaults.ShouldContain(vault => vault.IsPersonal);
|
|
}
|
|
|
|
/// <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 somebody has been added
|
|
/// 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);
|
|
|
|
// Two, and the creator is the other: their own self-grant is what makes a vault they just made
|
|
// readable at all, so a list that left it out would show the one person who can certainly open
|
|
// this vault as somebody who cannot.
|
|
vaults.Grants.Count.ShouldBe(2, vaults.Status);
|
|
|
|
var holder = vaults.Grants.Single(row => row.UserId == 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);
|
|
}
|
|
}
|