Public Access
Merge branch 'main' into claude/host-management-ui-plan-7f20ab
Seven files needed a hand. Most were two branches adding something in the same place, but three were one branch changing what the other had moved or renamed, and those are the ones worth reading. The shell keeps both new fields and both constructor lines: the connection recorder this branch built and the teams view model main did. Where main put a teams load inside OnScreenChanged, it now sits beside the logs refresh rather than inside RaiseSurfaceState — this branch extracted that notification block and it is called from two properties, so a screen-specific side effect in there would fire on every terminal switch as well. Main gave four row types a vault id and a vault name, and this branch had moved one of them — KnownHostRowViewModel — into its own file when the pinned keys became a screen. Git resolved that as "deleted here, modified there" and took the delete, which compiles as long as nobody looks: the moved copy still had the two-argument constructor and the call site had grown to four. Carried over by hand, along with the ordering the pins list now does on them. The status line's quiet rule was the subtle one. Main extracted it into IsWorthReporting; this branch had changed the same condition to read item counts rather than raw ones, because every user action queues a log entry a moment later and this machine reads its own entries back on the next pull. Take main's structure and the merge builds, passes, and silently restores a bug this branch existed partly to fix — every save's message overwritten a second after it appears. The method now reads PulledItems and PushedItems, with the reason in its remarks. Two conflicts were prose that had gone stale rather than code. The keychain screen's comment said team vaults are refused by the server's access service, which was true when it was written and is not now; main's replacement stands, in this branch's vocabulary. The design-gaps row for groups was claimed by both — real host groups here, per-vault headings there — and they are different things, so both rows stay and the difference is stated: a group is a shelf the user chose, a vault is who can read the item. One defect the tests found and the compiler could not. Generating a key opens the same editor as pasting one, but not through NewKey — so it never set the target vault main added, and a generated key was filed into whatever vault was edited last, or none. Both key-generation tests failed on it. Fixed where the editor opens, with the reason recorded there. One gap is left deliberately and is written down rather than half-built. Hosts, keys, credentials and pins are read across every vault this session holds a key for; groups are read from the active vault alone, so a host a teammate filed shows under UNGROUPED. Nothing is lost or misfiled — it is what the sidebar already shows for a group that has been deleted — but closing it needs a vault id on every group row for rename and delete, and a way to tell two vaults' identically-named groups apart under a layout with one heading per group. Both are worth doing and neither is a merge's business. It is in the remarks on ReloadGroupsAsync and in docs/design-import-gaps.md. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 1282 tests, including the end-to-end suite against real containers.
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The team, directory and grant half of the fake server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The key log is real.</b> Entries are chained with <see cref="KeyLogChain.ComputeEntryHash"/> exactly
|
||||
/// as the server chains them, because the client refuses to wrap a vault key to a directory answer that
|
||||
/// does not appear in a log whose chain verifies — so a fake that returned a plausible-looking log would
|
||||
/// make every sharing test pass against a check that was never exercised. It also means a test can break
|
||||
/// the chain deliberately and watch the client refuse.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Everything else is deliberately thin. Roles, slugs and idempotency are the server's rules and are
|
||||
/// tested against the real one in <c>DodoSSH.Api.Tests</c>; what the shell needs from here is that a team
|
||||
/// can be created, a member added, and a vault key wrapped and recorded.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultGrantApi
|
||||
{
|
||||
private readonly List<TeamSummary> teams = [];
|
||||
private readonly Dictionary<Guid, List<TeamMemberSummary>> members = [];
|
||||
private readonly Dictionary<Guid, VaultSummary> teamVaults = [];
|
||||
private readonly Dictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> grants = [];
|
||||
private readonly List<KeyLogRecord> keyLog = [];
|
||||
private readonly List<DirectoryEntry> directory = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public ITeamApi Teams => this;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDirectoryApi Directory => this;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultGrantApi Grants => this;
|
||||
|
||||
/// <summary>Grants this fake has been asked to record, for a test to assert on.</summary>
|
||||
internal IReadOnlyDictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> IssuedGrants => grants;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the log served omits its last entry's link, so its chain no longer verifies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The switch a test flips to prove the client refuses rather than shares. A fake with no way to be
|
||||
/// wrong can only ever confirm the happy path.
|
||||
/// </remarks>
|
||||
internal bool CorruptKeyLog { get; set; }
|
||||
|
||||
/// <summary>Registers another account, as though they had signed in and enrolled here.</summary>
|
||||
/// <returns>Their user id.</returns>
|
||||
internal Guid AddAccount(string email, string displayName)
|
||||
{
|
||||
var userId = Guid.CreateVersion7();
|
||||
|
||||
// Real keys rather than filler: the client recomputes the fingerprint over both halves and refuses
|
||||
// an entry whose fingerprint does not match, so random bytes would fail for the wrong reason.
|
||||
using var bundle = UserSecretBundle.Create(DateTimeOffset.UnixEpoch);
|
||||
|
||||
var sequence = AppendKeyLog(
|
||||
userId, bundle.EncryptionPublicKey, bundle.SigningPublicKey, new byte[64]);
|
||||
|
||||
directory.Add(new DirectoryEntry(
|
||||
userId,
|
||||
email,
|
||||
displayName,
|
||||
bundle.EncryptionPublicKey,
|
||||
bundle.SigningPublicKey,
|
||||
DshCrypto.ComputeFingerprint(bundle.EncryptionPublicKey, bundle.SigningPublicKey),
|
||||
KeyGeneration: 1,
|
||||
KeyLogSequence: sequence));
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<TeamSummary>>([.. teams]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamSummary> CreateTeamAsync(
|
||||
CreateTeamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var team = new TeamSummary(
|
||||
request.TeamId,
|
||||
request.Name,
|
||||
request.Slug,
|
||||
request.Description,
|
||||
TeamMemberRole.Owner,
|
||||
MemberCount: 1,
|
||||
VaultCount: 0,
|
||||
DateTimeOffset.UnixEpoch);
|
||||
|
||||
teams.Add(team);
|
||||
|
||||
members[team.TeamId] =
|
||||
[
|
||||
new TeamMemberSummary(
|
||||
UserId,
|
||||
"alice@example.com",
|
||||
"Alice Example",
|
||||
TeamMemberRole.Owner,
|
||||
TeamMemberStatus.Active,
|
||||
IsEnrolled: true,
|
||||
DateTimeOffset.UnixEpoch),
|
||||
];
|
||||
|
||||
return Task.FromResult(team);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<TeamMemberSummary>>(
|
||||
members.TryGetValue(teamId, out var list) ? [.. list] : []);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamMemberSummary> AddTeamMemberAsync(
|
||||
Guid teamId,
|
||||
AddTeamMemberRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = directory.Find(candidate => candidate.UserId == request.UserId)
|
||||
?? throw new DodoSshApiException(
|
||||
System.Net.HttpStatusCode.BadRequest,
|
||||
ProblemCodes.InvalidTeam,
|
||||
"No such account on this server.");
|
||||
|
||||
var member = new TeamMemberSummary(
|
||||
entry.UserId,
|
||||
entry.Email,
|
||||
entry.DisplayName,
|
||||
request.Role,
|
||||
TeamMemberStatus.Active,
|
||||
IsEnrolled: true,
|
||||
DateTimeOffset.UnixEpoch);
|
||||
|
||||
members[teamId] = [.. members.GetValueOrDefault(teamId, []), member];
|
||||
|
||||
Recount(teamId);
|
||||
|
||||
return Task.FromResult(member);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
ChangeTeamMemberRoleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var list = members.GetValueOrDefault(teamId, []);
|
||||
var index = list.FindIndex(member => member.UserId == userId);
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
throw new DodoSshApiException(
|
||||
System.Net.HttpStatusCode.BadRequest,
|
||||
ProblemCodes.InvalidTeam,
|
||||
"That account is not an active member of this team.");
|
||||
}
|
||||
|
||||
list[index] = list[index] with { Role = request.Role };
|
||||
|
||||
return Task.FromResult(list[index]);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> RemoveTeamMemberAsync(
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var list = members.GetValueOrDefault(teamId, []);
|
||||
var removed = list.RemoveAll(member => member.UserId == userId) > 0;
|
||||
|
||||
// Every grant they held from this team goes with them, as the real service revokes them in the
|
||||
// same transaction. A fake that removed the membership and left the grants would let a test
|
||||
// "prove" a revocation that had not happened.
|
||||
foreach (var vaultId in teamVaults.Values
|
||||
.Where(vault => vault.TeamId == teamId)
|
||||
.Select(vault => vault.VaultId))
|
||||
{
|
||||
grants.Remove((vaultId, userId));
|
||||
}
|
||||
|
||||
Recount(teamId);
|
||||
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<VaultSummary> CreateTeamVaultAsync(
|
||||
Guid teamId,
|
||||
CreateTeamVaultRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var vault = new VaultSummary(
|
||||
request.VaultId,
|
||||
request.Name,
|
||||
IsPersonal: false,
|
||||
TeamId: teamId,
|
||||
KeyGeneration: 1,
|
||||
Permissions: 31,
|
||||
request.WrappedVaultKey,
|
||||
RekeyRequired: false);
|
||||
|
||||
teamVaults[vault.VaultId] = vault;
|
||||
|
||||
Recount(teamId);
|
||||
|
||||
return Task.FromResult(vault);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
|
||||
string email,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<DirectoryEntry>>(
|
||||
[
|
||||
.. directory.Where(entry =>
|
||||
string.Equals(entry.Email, email, StringComparison.OrdinalIgnoreCase)),
|
||||
]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DirectoryEntry?> LookupByIdAsync(Guid userId, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(directory.Find(entry => entry.UserId == userId));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<KeyLogPage> ReadKeyLogAsync(
|
||||
long afterSequence,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var page = keyLog.Where(entry => entry.Sequence > afterSequence).ToList();
|
||||
|
||||
if (CorruptKeyLog && page.Count > 0)
|
||||
{
|
||||
// One byte, in the field the chain is built from. Enough to break the link and nothing else,
|
||||
// which is what a tampered log would look like.
|
||||
var last = page[^1];
|
||||
page[^1] = last with { EncryptionPublicKey = [.. last.EncryptionPublicKey.Reverse()] };
|
||||
}
|
||||
|
||||
var head = keyLog.Count == 0
|
||||
? KeyLogChain.CreateGenesisPreviousHash()
|
||||
: keyLog[^1].Hash;
|
||||
|
||||
return Task.FromResult(new KeyLogPage(page, keyLog.Count, head, HasMore: false));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<VaultGrantsResponse> ListVaultGrantsAsync(
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new VaultGrantsResponse(
|
||||
vaultId,
|
||||
KeyGeneration: 1,
|
||||
RekeyRequired: false,
|
||||
Grants:
|
||||
[
|
||||
.. grants.Where(entry => entry.Key.VaultId == vaultId).Select(entry =>
|
||||
new VaultGrantSummary(
|
||||
entry.Key.UserId,
|
||||
directory.Find(candidate => candidate.UserId == entry.Key.UserId)?.Email,
|
||||
null,
|
||||
KeyGeneration: 1,
|
||||
VaultGrantState.Active,
|
||||
UserId,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
null)),
|
||||
]));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task IssueVaultGrantAsync(
|
||||
Guid vaultId,
|
||||
IssueVaultGrantRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
grants[(vaultId, request.RecipientUserId)] = request;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> RevokeVaultGrantAsync(
|
||||
Guid vaultId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(grants.Remove((vaultId, userId)));
|
||||
|
||||
/// <summary>Publishes the enrolling account's own key, in the directory and the key log.</summary>
|
||||
private void RegisterSelf(KeyStatement statement, byte[] statementSignature)
|
||||
{
|
||||
if (directory.Exists(entry => entry.UserId == UserId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sequence = AppendKeyLog(
|
||||
UserId, statement.EncryptionPublicKey, statement.SigningPublicKey, statementSignature);
|
||||
|
||||
directory.Add(new DirectoryEntry(
|
||||
UserId,
|
||||
"alice@example.com",
|
||||
"Alice Example",
|
||||
statement.EncryptionPublicKey,
|
||||
statement.SigningPublicKey,
|
||||
DshCrypto.ComputeFingerprint(statement.EncryptionPublicKey, statement.SigningPublicKey),
|
||||
statement.KeyGeneration,
|
||||
sequence));
|
||||
}
|
||||
|
||||
/// <summary>Appends a key log entry, chained as the real log chains it.</summary>
|
||||
private long AppendKeyLog(
|
||||
Guid userId,
|
||||
byte[] encryptionPublicKey,
|
||||
byte[] signingPublicKey,
|
||||
byte[] statementSignature)
|
||||
{
|
||||
var previous = keyLog.Count == 0
|
||||
? KeyLogChain.CreateGenesisPreviousHash()
|
||||
: keyLog[^1].Hash;
|
||||
|
||||
var createdAt = KeyLogChain.TruncateTimestamp(DateTimeOffset.UnixEpoch);
|
||||
var sequence = keyLog.Count + 1;
|
||||
|
||||
var hash = KeyLogChain.ComputeEntryHash(
|
||||
previous, userId, 1, encryptionPublicKey, signingPublicKey, statementSignature, createdAt);
|
||||
|
||||
keyLog.Add(new KeyLogRecord(
|
||||
sequence,
|
||||
userId,
|
||||
Generation: 1,
|
||||
encryptionPublicKey,
|
||||
signingPublicKey,
|
||||
statementSignature,
|
||||
previous,
|
||||
hash,
|
||||
createdAt));
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
private void Recount(Guid teamId)
|
||||
{
|
||||
var index = teams.FindIndex(team => team.TeamId == teamId);
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
teams[index] = teams[index] with
|
||||
{
|
||||
MemberCount = members.GetValueOrDefault(teamId, []).Count,
|
||||
VaultCount = teamVaults.Values.Count(vault => vault.TeamId == teamId),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ namespace DodoSSH.Client.App.Tests;
|
||||
/// conflict behaviour is covered in <c>DodoSSH.Client.Sync.Tests</c> against a server that enforces
|
||||
/// version checks.
|
||||
/// </remarks>
|
||||
internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer
|
||||
internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer
|
||||
{
|
||||
private readonly List<SyncChange> log = [];
|
||||
|
||||
@@ -131,7 +131,11 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
|
||||
KeyGeneration: statement?.KeyGeneration,
|
||||
WrappedPrivateKey: wrappedPrivateKey,
|
||||
KdfParameters: kdfParameters,
|
||||
Vaults: personalVault is null ? [] : [personalVault]));
|
||||
|
||||
// Team vaults alongside the personal one, in the order the real /me returns them: this is
|
||||
// where a vault somebody shared arrives, and a fake that only ever reported the personal one
|
||||
// would make a refresh that admits a new vault untestable.
|
||||
Vaults: personalVault is null ? [] : [personalVault, .. teamVaults.Values]));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EnrollmentResponse> EnrollAsync(
|
||||
@@ -144,6 +148,10 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
|
||||
wrappedPrivateKey = request.WrappedPrivateKey;
|
||||
kdfParameters = request.KdfParameters;
|
||||
|
||||
// The enrolling account joins the directory and the key log, as it does on the real server. Both
|
||||
// are what a later share reads: this client verifies its own entry as part of verifying anyone's.
|
||||
RegisterSelf(request.Statement, request.StatementSignature);
|
||||
|
||||
personalVault = new VaultSummary(
|
||||
request.PersonalVault.VaultId,
|
||||
request.PersonalVault.Name,
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
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.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
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 teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
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 teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
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 teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
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 teams.CreateVaultCommand.ExecuteAsync(null);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user