using DodoSSH.Client.Storage; using static DodoSSH.Client.Sync.Tests.SyncHarness; namespace DodoSSH.Client.Sync.Tests; /// /// Two machines, one vault, every way they can disagree. /// /// /// The highest-value suite in the product, because this is the only place data can be lost. Every case /// asserts two things: that the two devices converge on the same state, and that whatever the merge had /// to override is recorded rather than gone. A merge that quietly drops the password someone just typed /// is worse than one that refuses to merge at all. /// public sealed class ConflictMatrixTests : IAsyncLifetime { private SyncHarness harness = null!; /// public async ValueTask InitializeAsync() => harness = await CreateAsync(); /// public ValueTask DisposeAsync() { harness.Dispose(); return ValueTask.CompletedTask; } // ---- The uncontested paths ---- [Fact] public async Task ACreatedHost_ReachesTheOtherMachine() { var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "primary")); await harness.SettleAsync(); var seen = await harness.Second.FindAsync(entityId); seen.Secret.Label.ShouldBe("prod-db"); seen.Secret.Notes.ShouldBe("primary"); seen.HasUnsyncedChanges.ShouldBeFalse(); harness.Server.RowCount.ShouldBe(1); } [Fact] public async Task AHostCreatedOffline_IsVisibleLocallyBeforeAnySync() { // The reason the outbox exists. A host typed in on a plane has to be usable on that plane. var entityId = await harness.First.CreateAsync(Host("prod-db")); var local = await harness.First.FindAsync(entityId); local.Secret.Label.ShouldBe("prod-db"); local.HasUnsyncedChanges.ShouldBeTrue(); harness.Server.RowCount.ShouldBe(0); } [Fact] public async Task ALocalOnlyEdit_IsPushed() { var entityId = await harness.First.CreateAsync(Host("prod-db")); await harness.SettleAsync(); await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "rotate quarterly")); await harness.SettleAsync(); (await harness.Second.FindAsync(entityId)).Secret.Notes.ShouldBe("rotate quarterly"); (await harness.First.ConflictsAsync()).ShouldBeEmpty(); } [Fact] public async Task ARemoteOnlyEdit_IsPulledWithoutAConflict() { var entityId = await harness.First.CreateAsync(Host("prod-db")); await harness.SettleAsync(); await harness.Second.UpdateAsync(entityId, Host("prod-db", username: "postgres")); await harness.SettleAsync(); (await harness.First.FindAsync(entityId)).Secret.Username.ShouldBe("postgres"); (await harness.First.ConflictsAsync()).ShouldBeEmpty(); } // ---- Both edited ---- [Fact] public async Task BothEditedDifferentFields_BothSurvive() { // The payoff for a field-level merge. Last-writer-wins would lose one of these. var entityId = await harness.First.CreateAsync(Host("prod-db")); await harness.SettleAsync(); await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "from the laptop")); await harness.Second.UpdateAsync(entityId, Host("prod-db", username: "postgres")); await harness.SettleAsync(); var merged = (await harness.First.FindAsync(entityId)).Secret; merged.Notes.ShouldBe("from the laptop"); merged.Username.ShouldBe("postgres"); (await harness.Second.FindAsync(entityId)).Secret.ShouldBe(merged); (await harness.First.ConflictsAsync()).ShouldBeEmpty(); } [Fact] public async Task BothAddedADifferentDirective_BothSurvive() { var entityId = await harness.First.CreateAsync(Host("prod-db")); await harness.SettleAsync(); await harness.First.UpdateAsync( entityId, Host("prod-db", options: [("Compression", "yes")])); await harness.Second.UpdateAsync( entityId, Host("prod-db", options: [("ServerAliveInterval", "30")])); await harness.SettleAsync(); var merged = (await harness.First.FindAsync(entityId)).Secret; merged.Options.Count.ShouldBe(2); merged.Options.TryGetValue("Compression", out _).ShouldBeTrue(); merged.Options.TryGetValue("ServerAliveInterval", out _).ShouldBeTrue(); } [Fact] public async Task BothEditedTheSameField_OneValueWinsAndTheOtherIsRecorded() { // A genuine clash. Whichever side loses, its value has to be retrievable — that is the entire // justification for resolving automatically instead of blocking. var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "original")); await harness.SettleAsync(); await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "from the laptop")); await harness.Second.UpdateAsync(entityId, Host("prod-db", notes: "from the desktop")); await harness.SettleAsync(); var first = (await harness.First.FindAsync(entityId)).Secret; var second = (await harness.Second.FindAsync(entityId)).Secret; first.ShouldBe(second); var winner = first.Notes.ShouldNotBeNull(); var lost = string.Equals(winner, "from the laptop", StringComparison.Ordinal) ? "from the desktop" : "from the laptop"; // One of the two, and the same one on both machines. Which is not the point; that the other is // retrievable is. new[] { "from the laptop", "from the desktop" } .Contains(winner, StringComparer.Ordinal) .ShouldBeTrue(); (await ConflictsAcrossDevicesAsync()) .ShouldContain(kind => kind == ConflictKind.FieldOverridden); (await DiscardedValuesAsync()) .ShouldContain( detail => detail.Contains(lost, StringComparison.Ordinal), "the overridden value must be recoverable from the conflict log"); } [Fact] public async Task BothMadeTheSameEdit_IsNotAConflict() { // Two people fixing the same typo must not be asked to arbitrate. var entityId = await harness.First.CreateAsync(Host("prod-db", hostname: "db.internl")); await harness.SettleAsync(); await harness.First.UpdateAsync(entityId, Host("prod-db", hostname: "db.internal")); await harness.Second.UpdateAsync(entityId, Host("prod-db", hostname: "db.internal")); await harness.SettleAsync(); (await harness.First.FindAsync(entityId)).Secret.Hostname.ShouldBe("db.internal"); (await ConflictsAcrossDevicesAsync()).ShouldBeEmpty(); } // ---- Deletes ---- [Fact] public async Task ADeletedHost_DisappearsEverywhere() { var entityId = await harness.First.CreateAsync(Host("prod-db")); await harness.SettleAsync(); await harness.First.DeleteAsync(entityId); await harness.SettleAsync(); (await harness.First.ListAsync()).Items.ShouldBeEmpty(); (await harness.Second.ListAsync()).Items.ShouldBeEmpty(); harness.Server.RowCount.ShouldBe(0); } [Fact] public async Task BothDeleted_IsNotAConflict() { var entityId = await harness.First.CreateAsync(Host("prod-db")); await harness.SettleAsync(); await harness.First.DeleteAsync(entityId); await harness.Second.DeleteAsync(entityId); await harness.SettleAsync(); (await harness.First.ListAsync()).Items.ShouldBeEmpty(); (await harness.Second.ListAsync()).Items.ShouldBeEmpty(); (await ConflictsAcrossDevicesAsync()).ShouldBeEmpty(); } [Fact] public async Task DeletedElsewhereWhileEditedHere_TheLocalWorkSurvivesUnderANewName() { // The case where naive handling loses data outright. The tombstone has to stand — arguing with it // conflicts for ever — so the edit is preserved as a separate host instead of being dropped. var entityId = await harness.First.CreateAsync(Host("prod-db")); await harness.SettleAsync(); // The laptop syncs first, so its delete is what reaches the server; the desktop's edit is the // one that has to be rescued. Which side loses is decided by who gets there first, and both // orderings are covered — see EditedElsewhereWhileDeletedHere for the mirror image. await harness.First.DeleteAsync(entityId); await harness.Second.UpdateAsync( entityId, Host("prod-db", notes: "credentials rotated, do not delete")); await harness.SettleAsync(); var listing = await harness.First.ListAsync(); var restored = listing.Items.ShouldHaveSingleItem(); restored.EntityId.ShouldNotBe(entityId); restored.Secret.Label.ShouldBe("prod-db (restored)"); restored.Secret.Notes.ShouldBe("credentials rotated, do not delete"); (await ConflictsAcrossDevicesAsync()) .ShouldContain(kind => kind == ConflictKind.RemoteDeleteResurrected); // And the other machine sees it too, so the rescue is not local-only. (await harness.Second.ListAsync()).Items.ShouldHaveSingleItem() .EntityId.ShouldBe(restored.EntityId); } [Fact] public async Task ReplayingAPulledDeletion_DoesNotDuplicateTheRescuedCopy() { // Applying a pulled change is at-least-once — the cursor is saved after the page is applied — so // a whole page can arrive twice. This checks the replay is harmless end to end; the deterministic // id it relies on is pinned by ResurrectionIdTests. var entityId = await harness.First.CreateAsync(Host("prod-db")); await harness.SettleAsync(); await harness.First.DeleteAsync(entityId); await harness.Second.UpdateAsync(entityId, Host("prod-db", notes: "keep me")); await harness.First.SyncAsync(); // Rewind the desktop's cursor, so the deletion arrives a second time and it tries to rescue the // same content twice. await harness.Second.SyncAsync(); await harness.Second.SyncState.ResetAsync(VaultId, TestContext.Current.CancellationToken); await harness.Second.SyncAsync(); await harness.SettleAsync(); (await harness.First.ListAsync()).Items.Count.ShouldBe(1); harness.Server.RowCount.ShouldBe(1); } [Fact] public async Task EditedElsewhereWhileDeletedHere_TheDeleteIsAbandonedAndReported() { // The mirror image, and resolved the same way round: an edit outlives a removal. Re-deleting // costs a click; a discarded edit may be the only copy of something. var entityId = await harness.First.CreateAsync(Host("prod-db")); await harness.SettleAsync(); await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "still needed")); await harness.Second.DeleteAsync(entityId); await harness.SettleAsync(); var survivor = await harness.First.FindAsync(entityId); survivor.Secret.Notes.ShouldBe("still needed"); (await ConflictsAcrossDevicesAsync()) .ShouldContain(kind => kind == ConflictKind.LocalDeleteOverridden); } // ---- Ordering, retries and idempotence ---- [Fact] public async Task OfflineChanges_ArePushedInTheOrderTheyWereMade() { var first = await harness.First.CreateAsync(Host("a-bastion")); var second = await harness.First.CreateAsync(Host("b-database")); var third = await harness.First.CreateAsync(Host("c-cache")); await harness.SettleAsync(); var order = (await harness.Second.ListAsync()).Items .OrderBy(host => host.Version) .ThenBy(host => host.Secret.Label, StringComparer.Ordinal) .Select(host => host.EntityId) .ToArray(); order.ShouldBe([first, second, third], ignoreOrder: false); } [Fact] public async Task ASecondSyncPass_ChangesNothing() { // Idempotence, which is what makes re-reading a client's own writes a safe way to avoid the // cursor-gap hazard in the push response. await harness.First.CreateAsync(Host("prod-db")); await harness.SettleAsync(); var before = await harness.First.HostsSortedAsync(); var pushesBefore = harness.Server.PushCount; var report = await harness.First.SyncAsync(); report.Pushed.ShouldBe(0); harness.Server.PushCount.ShouldBe(pushesBefore); (await harness.First.HostsSortedAsync()).ShouldBe(before); } [Fact] public async Task AnEditWhileAnEarlierPushIsUnacknowledged_KeepsTheNewerValueWithoutDuplicating() { // A create that reached the server and whose answer did not come back, followed by another edit. // The newer value has to win and there must be exactly one host afterwards. Two mechanisms keep // that true: the pull sees the server's row and re-bases the queued edit onto it, and a coalesced // row carries a fresh operation id so the server cannot answer Duplicate — "already applied" — // for an operation whose contents have since changed. The second is pinned directly by // OutboxStoreTests.ACoalescedEdit_GetsAFreshOperationId; here they are exercised together. var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "first")); // The create lands on the server, but the acknowledgement never reaches the laptop. await PushBehindTheEnginesBackAsync(entityId); await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "second")); await harness.SettleAsync(); harness.Server.RowCount.ShouldBe(1); (await harness.First.FindAsync(entityId)).Secret.Notes.ShouldBe("second"); (await harness.Second.FindAsync(entityId)).Secret.Notes.ShouldBe("second"); } [Fact] public async Task AnUnacknowledgedCreateThatDidLand_IsDroppedQuietly() { // The same lost acknowledgement as above, but with no subsequent edit — which is the common case, // since a timeout is far more likely than a timeout followed by a change. The queued create meets // the server's own copy of itself, and the only correct answer is to stop trying to send it. In // particular this must not be reported as a conflict: there is nothing for a person to decide, and a // vault that produced a conflict notice every time a push timed out would train people to ignore // them. // // This is the one path that compares two decrypted items for equality, and the comparison has to go // through EqualityComparer rather than ==, because the reconciler is generic over the secret type // and == on a type parameter is reference equality. var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "first")); await PushBehindTheEnginesBackAsync(entityId); await harness.SettleAsync(); (await ConflictsAcrossDevicesAsync()).ShouldBeEmpty( "an item that came back exactly as it was sent is not something to arbitrate"); // And the operation is gone rather than still being offered. (await harness.First.Outbox.TakeAsync(VaultId, 100, TestContext.Current.CancellationToken)) .ShouldBeEmpty(); harness.Server.RowCount.ShouldBe(1); (await harness.First.FindAsync(entityId)).HasUnsyncedChanges.ShouldBeFalse(); } [Fact] public async Task AConcurrentWriteDuringAPush_IsNotSkipped() { // The cursor-gap hazard. The push response carries a cursor sitting after this client's own // changes; adopting it would skip anything another client committed at a lower sequence in the // window between this client's pull and its push. The engine keeps its own cursor instead. var mine = await harness.First.CreateAsync(Host("mine")); var theirs = Guid.CreateVersion7(); harness.Server.OnPush = () => harness.Server.ExternalUpsert(theirs, ForeignPayload(), null); await harness.First.SyncAsync(); var ids = (await harness.First.Items .ListAsync(VaultId, Contracts.SyncEntityType.Host, false, TestContext.Current.CancellationToken)) .Select(item => item.EntityId) .ToArray(); ids.ShouldContain(mine); ids.ShouldContain(theirs, "a change committed during the push was skipped"); } [Fact] public async Task AForbiddenWrite_IsParkedRatherThanRetriedForever() { var entityId = await harness.First.CreateAsync(Host("prod-db")); harness.Server.DenyWrites = true; var report = await harness.First.SyncAsync(); report.Parked.ShouldBe(1); (await harness.First.Outbox.ListParkedAsync(VaultId, TestContext.Current.CancellationToken)) .ShouldHaveSingleItem().EntityId.ShouldBe(entityId); // A parked operation is not retried, so a second pass sends nothing. var pushes = harness.Server.PushCount; await harness.First.SyncAsync(); harness.Server.PushCount.ShouldBe(pushes); (await harness.First.ConflictsAsync()).ShouldContain(c => c.Kind == ConflictKind.Rejected); } [Fact] public async Task AParkedChange_IsStillWhatTheUserSees() { // Hiding it because the server refused would show the old values and look like the edit was lost. var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "mine")); harness.Server.DenyWrites = true; await harness.First.SyncAsync(); var host = await harness.First.FindAsync(entityId); host.Secret.Notes.ShouldBe("mine"); host.IsBlocked.ShouldBeTrue(); host.HasUnsyncedChanges.ShouldBeTrue(); } [Fact] public async Task ARekeyedVault_IsReportedRatherThanShowingAnEmptyList() { // After a rekey this client's grant is stale, so items pulled meanwhile cannot be read. Silently // showing nothing would look exactly like an empty vault. await harness.First.CreateAsync(Host("prod-db")); await harness.SettleAsync(); harness.Server.KeyGeneration = 2; var report = await harness.First.SyncAsync(); report.RekeyRequired.ShouldBeTrue(); report.ServerKeyGeneration.ShouldBe(2u); report.NeedsAttention.ShouldBeTrue(); } // ---- The overall property ---- [Fact] public async Task AfterAnInterleavedSession_BothMachinesAgree() { // Convergence, over a fixed script that exercises creates, edits, a delete and a resurrection at // once. Two devices that ended up with different host lists would be the worst possible outcome // for a synced vault, and no single-case test rules it out. var shared = await harness.First.CreateAsync(Host("a-shared")); var doomed = await harness.First.CreateAsync(Host("b-doomed")); await harness.SettleAsync(); await harness.First.UpdateAsync(shared, Host("a-shared", notes: "laptop note")); await harness.Second.UpdateAsync(shared, Host("a-shared", username: "desktop-user")); await harness.First.DeleteAsync(doomed); await harness.Second.UpdateAsync(doomed, Host("b-doomed", notes: "still wanted")); await harness.First.CreateAsync(Host("c-laptop-only")); await harness.Second.CreateAsync(Host("d-desktop-only")); await harness.SettleAsync(); await harness.SettleAsync(); var first = await harness.First.HostsSortedAsync(); var second = await harness.Second.HostsSortedAsync(); first.ShouldBe(second); first.Count.ShouldBe(4); first.Select(host => host.Label).ShouldBe( ["a-shared", "b-doomed (restored)", "c-laptop-only", "d-desktop-only"]); // The merged host kept both sides' contributions. var merged = first.Single(host => string.Equals(host.Label, "a-shared", StringComparison.Ordinal)); merged.Notes.ShouldBe("laptop note"); merged.Username.ShouldBe("desktop-user"); // And nothing is still queued anywhere. (await harness.First.Outbox.TakeAsync(VaultId, 100, TestContext.Current.CancellationToken)) .ShouldBeEmpty(); (await harness.Second.Outbox.TakeAsync(VaultId, 100, TestContext.Current.CancellationToken)) .ShouldBeEmpty(); } // ---- Helpers ---- private static Contracts.EncryptedPayload ForeignPayload() => // Deliberately not decryptable: this stands in for another user's item, and the point of the test // is only that the change is not skipped. Pulling never decrypts, so nothing here needs to open. new([1, 2, 3], [4, 5], Guid.CreateVersion7(), 1, Crypto.CryptoSpec.CurrentAadVersion); /// /// Sends a device's queued operation straight to the server, leaving the outbox row in place. /// /// /// Reproduces the one situation the engine cannot reach on its own: a push that the server applied /// and whose answer never came back. That window is where an idempotency key either saves the newer /// edit or destroys it. /// private async Task PushBehindTheEnginesBackAsync(Guid entityId) { var pending = await harness.First.Outbox.FindAsync( VaultId, Contracts.SyncEntityType.Host, entityId, TestContext.Current.CancellationToken); pending.ShouldNotBeNull(); await harness.Server.SyncPushAsync( VaultId, new Contracts.SyncPushRequest( [ new Contracts.SyncPushOperation( pending.OperationId, pending.EntityType, pending.EntityId, pending.Operation, pending.ExpectedVersion, pending.Payload, pending.Fields), ]), TestContext.Current.CancellationToken); } private async Task> ConflictsAcrossDevicesAsync() { var first = await harness.First.ConflictsAsync(); var second = await harness.Second.ConflictsAsync(); return [.. first.Concat(second).Select(conflict => conflict.Kind)]; } private async Task> DiscardedValuesAsync() { 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)), ]; } }