using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using static DodoSSH.Client.Sync.Tests.SyncHarness;
namespace DodoSSH.Client.Sync.Tests;
///
/// Credentials through the two-machine harness.
///
///
/// Shorter still than , and that is the payoff of the shared reconciler: the six
/// collision outcomes are one implementation and are already exercised. What is left to check per type is its
/// cipher, what it tells the server, that its items cannot be confused with another type's, and that the one
/// thing which must never be logged is not logged.
///
public sealed class CredentialSyncTests : IAsyncLifetime
{
private SyncHarness harness = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
///
public async ValueTask InitializeAsync() => harness = await CreateAsync();
///
public ValueTask DisposeAsync()
{
harness.Dispose();
return ValueTask.CompletedTask;
}
[Fact]
public async Task ACredentialCreatedOnOneMachine_ReachesTheOther()
{
var entityId = await harness.First.CreateCredentialAsync(
Credential("prod-db", password: "hunter2", username: "postgres", notes: "rotate in June"));
await harness.SettleAsync();
var seen = await harness.Second.FindCredentialAsync(entityId);
seen.Secret.Label.ShouldBe("prod-db");
seen.Secret.Password.ShouldBe("hunter2");
seen.Secret.Username.ShouldBe("postgres");
seen.Secret.Notes.ShouldBe("rotate in June");
seen.HasUnsyncedChanges.ShouldBeFalse();
}
[Fact]
public async Task ThePull_AsksForAllThreeTypes()
{
await harness.First.SyncAsync();
var asked = harness.Server.LastPullTypes.ShouldNotBeNull();
asked.ShouldContain(SyncEntityType.Host);
asked.ShouldContain(SyncEntityType.SshKey);
asked.ShouldContain(SyncEntityType.Credential);
}
[Fact]
public async Task ACredentialHandsTheServerNothingInPlaintext()
{
var entityId = await harness.First.CreateCredentialAsync(Credential("prod-db"));
var queued = await harness.First.Outbox
.FindAsync(VaultId, SyncEntityType.Credential, entityId, Token);
queued.ShouldNotBeNull();
queued.Fields.ShouldBeNull("nothing about a password is safe to hold in the clear");
await harness.SettleAsync();
var row = harness.Server.Find(entityId, SyncEntityType.Credential).ShouldNotBeNull();
row.Fields.RelayEnabled.ShouldBeFalse();
row.Fields.Hostname.ShouldBeNull();
row.Fields.PublicKeyFingerprint.ShouldBeNull();
}
[Fact]
public async Task ThreeTypesSharingOneId_AreThreeItems()
{
// The cache keys on the type as well as the id, and each payload's AAD binds a different resource
// type — two defences, independently. Arranged on the server because the repositories mint UUIDv7s
// and would never collide.
var sharedId = Guid.CreateVersion7();
harness.First.Keyring.TryGet(VaultId, out var vaultKey, out var generation).ShouldBeTrue();
harness.Server.ExternalUpsert(
sharedId,
HostCipher.Seal(Host("prod-db"), vaultKey.Span, sharedId, generation, itemVersion: 1),
new SyncPlaintextFields(),
SyncEntityType.Host);
harness.Server.ExternalUpsert(
sharedId,
SshKeyCipher.Seal(Key("deploy"), vaultKey.Span, sharedId, generation, itemVersion: 1),
null,
SyncEntityType.SshKey);
harness.Server.ExternalUpsert(
sharedId,
CredentialCipher.Seal(
Credential("db-login"), vaultKey.Span, sharedId, generation, itemVersion: 1),
null,
SyncEntityType.Credential);
await harness.Second.SyncAsync();
(await harness.Second.ListAsync()).Items.ShouldHaveSingleItem()
.Secret.Label.ShouldBe("prod-db");
(await harness.Second.ListKeysAsync()).Items.ShouldHaveSingleItem()
.Secret.Label.ShouldBe("deploy");
var credentials = await harness.Second.ListCredentialsAsync();
credentials.Items.ShouldHaveSingleItem().Secret.Label.ShouldBe("db-login");
credentials.Unreadable.ShouldBe(0);
}
[Fact]
public async Task TwoMachinesEditingDifferentFields_BothSurvive()
{
var entityId = await harness.First.CreateCredentialAsync(Credential("prod-db"));
await harness.SettleAsync();
await harness.First.UpdateCredentialAsync(entityId, Credential("prod-db-primary"));
await harness.Second.UpdateCredentialAsync(
entityId, Credential("prod-db", notes: "from the desktop"));
await harness.SettleAsync();
var first = (await harness.First.FindCredentialAsync(entityId)).Secret;
first.ShouldBe((await harness.Second.FindCredentialAsync(entityId)).Secret);
first.Label.ShouldBe("prod-db-primary");
first.Notes.ShouldBe("from the desktop");
(await ConflictKindsAsync()).ShouldBeEmpty();
}
[Fact]
public async Task BothChangedThePassword_NeitherReachesTheConflictLog()
{
var entityId = await harness.First.CreateCredentialAsync(Credential("prod-db"));
await harness.SettleAsync();
await harness.First.UpdateCredentialAsync(entityId, Credential("prod-db", "LAPTOP-SECRET"));
await harness.Second.UpdateCredentialAsync(entityId, Credential("prod-db", "DESKTOP-SECRET"));
await harness.SettleAsync();
(await ConflictKindsAsync()).ShouldContain(kind => kind == ConflictKind.FieldOverridden);
var details = await ConflictDetailsAsync();
details.ShouldContain(
detail => detail.Contains("Password", StringComparison.Ordinal),
"the user still has to be told which field clashed");
foreach (var detail in details)
{
detail.ShouldNotContain("LAPTOP-SECRET");
detail.ShouldNotContain("DESKTOP-SECRET");
}
}
[Fact]
public async Task ACredentialEditedElsewhereAfterBeingDeletedHere_IsCalledACredential()
{
var entityId = await harness.First.CreateCredentialAsync(Credential("prod-db"));
await harness.SettleAsync();
await harness.First.UpdateCredentialAsync(entityId, Credential("prod-db", notes: "still in use"));
await harness.Second.Credentials.DeleteAsync(VaultId, entityId, Token);
await harness.SettleAsync();
(await harness.First.FindCredentialAsync(entityId)).Secret.Notes.ShouldBe("still in use");
var details = await ConflictDetailsAsync();
details.ShouldContain(
detail => detail.Contains("This credential was edited", StringComparison.Ordinal));
details.ShouldNotContain(
detail => detail.Contains("This host was edited", StringComparison.Ordinal));
}
private async Task> ConflictKindsAsync()
{
var first = await harness.First.ConflictsAsync();
var second = await harness.Second.ConflictsAsync();
return [.. first.Concat(second).Select(conflict => conflict.Kind)];
}
private async Task> ConflictDetailsAsync()
{
var first = await harness.First.ConflictsAsync();
var second = await harness.Second.ConflictsAsync();
return
[
.. first.Concat(second)
.Select(conflict => System.Text.Encoding.UTF8.GetString(conflict.Detail)),
];
}
}