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)), ]; } }