using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync.Tests;
///
/// One machine: its own cache, its own outbox, its own view of the vault.
///
///
/// A separate SQLite database per device, because the whole subject of these tests is two caches
/// diverging and being reconciled. Sharing one would make every conflict test vacuous.
///
internal sealed class SyncDevice : IDisposable
{
private readonly ClientCacheFactory factory;
private readonly LocalCacheProtector protector;
private SyncDevice(
string name,
ClientCacheFactory factory,
LocalCacheProtector protector,
VaultKeyring keyring,
FakeVaultServer server,
SyncOptions options)
{
Name = name;
this.factory = factory;
this.protector = protector;
Keyring = keyring;
Items = new ItemStore(factory, protector);
Outbox = new OutboxStore(factory, protector, TimeProvider.System);
SyncState = new SyncStateStore(factory);
Conflicts = new ConflictStore(factory, protector, TimeProvider.System);
Hosts = new HostRepository(Items, Outbox, keyring);
SshKeys = new SshKeyRepository(Items, Outbox, keyring);
Credentials = new CredentialRepository(Items, Outbox, keyring);
KnownHosts = new KnownHostRepository(Items, Outbox, keyring);
Engine = new SyncEngine(
server, Items, Outbox, SyncState, Conflicts, keyring, TimeProvider.System, options);
}
internal string Name { get; }
internal VaultKeyring Keyring { get; }
internal ItemStore Items { get; }
internal OutboxStore Outbox { get; }
internal SyncStateStore SyncState { get; }
internal ConflictStore Conflicts { get; }
internal HostRepository Hosts { get; }
internal SshKeyRepository SshKeys { get; }
internal CredentialRepository Credentials { get; }
internal KnownHostRepository KnownHosts { get; }
internal SyncEngine Engine { get; }
internal static async Task CreateAsync(
string name,
UserSecretBundle bundle,
StoredVault vault,
FakeVaultServer server,
SyncOptions options)
{
var cache = ClientCacheFactory.ForMemory($"sync-{name}-{Guid.CreateVersion7():N}");
try
{
await cache.MigrateAsync(TestContext.Current.CancellationToken);
// Opened through the real grant, so the keyring, the wrap and the AAD are all exercised.
var keyring = VaultKeyring.Open(bundle, [vault]);
// Both simulated machines derive the same cache key, because they are the same user holding the
// same identity — which is what keying the cache on the bundle means. They still have separate
// cache databases, so nothing is shared between them but the key that would open either.
return new SyncDevice(
name, cache, LocalCacheProtector.From(bundle), keyring, server, options);
}
catch
{
cache.Dispose();
throw;
}
}
internal Task SyncAsync() =>
Engine.SyncAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
internal Task> ListAsync() =>
Hosts.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
internal async Task> HostsSortedAsync()
{
var listing = await ListAsync();
return [.. listing.Items.Select(h => h.Secret).OrderBy(h => h.Label, StringComparer.Ordinal)];
}
internal async Task> FindAsync(Guid entityId)
{
var listing = await ListAsync();
return listing.Items.SingleOrDefault(host => host.EntityId == entityId)
?? throw new InvalidOperationException($"{Name} cannot see host {entityId}.");
}
internal Task CreateAsync(HostSecret host) =>
Hosts.CreateAsync(SyncHarness.VaultId, host, TestContext.Current.CancellationToken);
internal Task UpdateAsync(Guid entityId, HostSecret host) =>
Hosts.UpdateAsync(SyncHarness.VaultId, entityId, host, TestContext.Current.CancellationToken);
internal Task DeleteAsync(Guid entityId) =>
Hosts.DeleteAsync(SyncHarness.VaultId, entityId, TestContext.Current.CancellationToken);
// ---- The same four operations, on SSH keys ----
internal Task> ListKeysAsync() =>
SshKeys.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
internal async Task> FindKeyAsync(Guid entityId)
{
var listing = await ListKeysAsync();
return listing.Items.SingleOrDefault(key => key.EntityId == entityId)
?? throw new InvalidOperationException($"{Name} cannot see key {entityId}.");
}
internal Task CreateKeyAsync(SshKeySecret key) =>
SshKeys.CreateAsync(SyncHarness.VaultId, key, TestContext.Current.CancellationToken);
internal Task UpdateKeyAsync(Guid entityId, SshKeySecret key) =>
SshKeys.UpdateAsync(SyncHarness.VaultId, entityId, key, TestContext.Current.CancellationToken);
internal Task DeleteKeyAsync(Guid entityId) =>
SshKeys.DeleteAsync(SyncHarness.VaultId, entityId, TestContext.Current.CancellationToken);
// ---- And again on credentials ----
internal Task> ListCredentialsAsync() =>
Credentials.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
internal async Task> FindCredentialAsync(Guid entityId)
{
var listing = await ListCredentialsAsync();
return listing.Items.SingleOrDefault(credential => credential.EntityId == entityId)
?? throw new InvalidOperationException($"{Name} cannot see credential {entityId}.");
}
internal Task CreateCredentialAsync(CredentialSecret credential) =>
Credentials.CreateAsync(SyncHarness.VaultId, credential, TestContext.Current.CancellationToken);
internal Task UpdateCredentialAsync(Guid entityId, CredentialSecret credential) =>
Credentials.UpdateAsync(
SyncHarness.VaultId, entityId, credential, TestContext.Current.CancellationToken);
// ---- And again on known host keys ----
internal Task> ListKnownHostsAsync() =>
KnownHosts.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
internal async Task> FindKnownHostAsync(Guid entityId)
{
var listing = await ListKnownHostsAsync();
return listing.Items.SingleOrDefault(pin => pin.EntityId == entityId)
?? throw new InvalidOperationException($"{Name} cannot see known host key {entityId}.");
}
internal Task CreateKnownHostAsync(KnownHostSecret knownHost) =>
KnownHosts.CreateAsync(SyncHarness.VaultId, knownHost, TestContext.Current.CancellationToken);
internal Task UpdateKnownHostAsync(Guid entityId, KnownHostSecret knownHost) =>
KnownHosts.UpdateAsync(
SyncHarness.VaultId, entityId, knownHost, TestContext.Current.CancellationToken);
internal Task DeleteKnownHostAsync(Guid entityId) =>
KnownHosts.DeleteAsync(SyncHarness.VaultId, entityId, TestContext.Current.CancellationToken);
internal Task> ConflictsAsync() =>
Conflicts.ListAsync(SyncHarness.VaultId, false, TestContext.Current.CancellationToken);
///
public void Dispose()
{
Keyring.Dispose();
protector.Dispose();
factory.Dispose();
}
}
///
/// One user, one vault, two machines and a server.
///
///
/// Both devices share the identity bundle, which is what a single user on a laptop and a desktop
/// actually looks like: one enrolled key pair, one vault grant, two independent local caches. That is
/// also the cheapest realistic setup in which every conflict case can be produced.
///
internal sealed class SyncHarness : IDisposable
{
internal static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly UserSecretBundle bundle;
private SyncHarness(UserSecretBundle bundle, FakeVaultServer server, SyncDevice first, SyncDevice second)
{
this.bundle = bundle;
Server = server;
First = first;
Second = second;
}
internal static Guid VaultId { get; } = Guid.Parse("0192f0c8-7777-7c3d-8e4f-5a6b7c8d9e0f");
internal FakeVaultServer Server { get; }
/// The laptop.
internal SyncDevice First { get; }
/// The desktop.
internal SyncDevice Second { get; }
internal static async Task CreateAsync(SyncOptions? options = null)
{
var effective = options ?? SyncOptions.Default;
var identity = UserSecretBundle.Create(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000));
try
{
var vaultKey = VaultKeys.Create();
var wrapped = VaultKeys.WrapTo(vaultKey, identity.EncryptionPublicKey, VaultId, 1);
// The plaintext key is not retained: each device unwraps the grant itself, as it would after
// an ordinary unlock.
System.Security.Cryptography.CryptographicOperations.ZeroMemory(vaultKey);
var vault = new StoredVault(
VaultId, "Personal", IsPersonal: true, TeamId: null, KeyGeneration: 1,
Permissions: 31, wrapped, RekeyRequired: false);
var server = new FakeVaultServer(VaultId);
var first = await SyncDevice.CreateAsync("laptop", identity, vault, server, effective);
try
{
var second = await SyncDevice.CreateAsync("desktop", identity, vault, server, effective);
return new SyncHarness(identity, server, first, second);
}
catch
{
first.Dispose();
throw;
}
}
catch
{
identity.Dispose();
throw;
}
}
/// Brings both devices up to date, twice, so the result is a settled state.
///
/// Twice because one pass per device is not enough for a change made on one to be merged on the
/// other and then pushed back. Asserting on a settled state rather than on an intermediate one is
/// what makes "the two devices converge" a meaningful claim.
///
internal async Task SettleAsync()
{
for (var round = 0; round < 2; round++)
{
await First.SyncAsync();
await Second.SyncAsync();
}
}
///
public void Dispose()
{
First.Dispose();
Second.Dispose();
bundle.Dispose();
}
// ---- Builders ----
internal static HostSecret Host(
string label,
string hostname = "db.internal",
int port = 22,
string? username = "deploy",
string? notes = null,
(string Name, string Value)[]? options = null,
bool relayEnabled = false) =>
new()
{
Label = label,
Hostname = hostname,
Port = port,
Username = username,
Notes = notes,
Options = options is null
? HostOptions.Empty
: HostOptions.Create(options.Select(o => new HostOption(o.Name, o.Value))),
RelayEnabled = relayEnabled,
};
///
/// An SSH key whose material is a plausible shape but not a real key.
///
///
/// Not a valid Ed25519 key, and deliberately so: nothing in the sync path parses the material, and a
/// real private key checked into a test repository is a real private key on the internet regardless of
/// what it was used for. SshKeySecret.TryValidate only requires the armour, and the tests that
/// need a key SSH.NET can actually load live in DodoSSH.Client.Ssh.Tests where one is generated.
///
/// A credential for the suites, varying only what a test is about.
internal static CredentialSecret Credential(
string label,
string password = "hunter2",
string? username = null,
string? notes = null) =>
new() { Label = label, Password = password, Username = username, Notes = notes };
/// A pinned host key, varying only what a test is about.
///
/// The fingerprint is a plausible shape rather than a real digest. Nothing in the sync path hashes
/// anything or checks the encoding — SshHostKeyFingerprint does that, one layer down and in its own
/// suite — so a value that reads as one is worth more here than a genuine one.
///
internal static KnownHostSecret KnownHost(
string host = "db.internal",
int port = 22,
string algorithm = "ssh-ed25519",
string fingerprint = "SHA256:AAAAtestfingerprint0123456789abcdefghijklmno") =>
new()
{
Host = host,
Port = port,
Algorithm = algorithm,
Fingerprint = fingerprint,
};
internal static SshKeySecret Key(
string label,
string material = "deploy-key-material",
string? passphrase = null,
string? publicKey = null,
string? notes = null) =>
new()
{
Label = label,
PrivateKeyPem = $"-----BEGIN OPENSSH PRIVATE KEY-----\n{material}\n"
+ "-----END OPENSSH PRIVATE KEY-----\n",
Passphrase = passphrase,
PublicKey = publicKey,
Notes = notes,
};
}