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 MasterKey master;
private readonly LocalCacheProtector protector;
private SyncDevice(
string name,
ClientCacheFactory factory,
MasterKey master,
LocalCacheProtector protector,
VaultKeyring keyring,
FakeVaultServer server,
SyncOptions options)
{
Name = name;
this.factory = factory;
this.master = master;
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);
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 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);
var derived = MasterKey.Derive(
$"passphrase-{name}", new byte[CryptoSpec.SaltSize], SyncHarness.CheapProfile);
// Opened through the real grant, so the keyring, the wrap and the AAD are all exercised.
var keyring = VaultKeyring.Open(bundle, [vault]);
return new SyncDevice(
name, cache, derived, LocalCacheProtector.From(derived), 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.Hosts.Select(h => h.Host).OrderBy(h => h.Label, StringComparer.Ordinal)];
}
internal async Task FindAsync(Guid entityId)
{
var listing = await ListAsync();
return listing.Hosts.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);
internal Task> ConflictsAsync() =>
Conflicts.ListAsync(SyncHarness.VaultId, false, TestContext.Current.CancellationToken);
///
public void Dispose()
{
Keyring.Dispose();
protector.Dispose();
master.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,
};
}