namespace DodoSSH.Client.Sync.Tests; /// /// Moving a rotated vault's stored items onto its current key. /// /// /// /// A rotation re-keys the vault and not its contents, which is what makes it cheap and safe (ADR 0010) /// and what leaves this pass to be run. The claim it has to earn is narrow and testable: after it, the /// key the vault has moved off opens nothing. Every test here that says "resealed" also checks that, /// because a pass that re-wrapped everything under the same key would report exactly the same numbers. /// /// /// The other half is the push path. A change queued before a rotation is sealed under the old key, and /// sending it as it stands would put a brand-new item into the vault under the key the person who was /// just removed still holds — the one hole a pass over stored items cannot see. /// /// public sealed class VaultResealTests { [Fact] public async Task ARotatedVault_MovesItsStoredItemsOntoTheNewKey() { using var harness = await SyncHarness.CreateAsync(); var web = await harness.First.CreateAsync(SyncHarness.Host("web-01")); var db = await harness.First.CreateAsync(SyncHarness.Host("db-01")); await harness.SettleAsync(); var superseded = harness.Rotate(); var report = await harness.First.ResealAsync(); report.Resealed.ShouldBe(2); report.Complete.ShouldBeTrue(); report.KeyGeneration.ShouldBe(2u); foreach (var entityId in (Guid[])[web, db]) { var row = harness.Server.Find(entityId).ShouldNotBeNull(); row.Payload.KeyGeneration.ShouldBe(2u); // The point of the whole pass: the key somebody left with opens nothing here any more. HostCipher.TryOpen(row.Payload, superseded, entityId, row.Version).ShouldBeNull(); } // And the vault still reads as itself — the plaintext was carried across, not re-encoded. var hosts = await harness.First.HostsSortedAsync(); hosts.Select(host => host.Label).ShouldBe(["db-01", "web-01"]); } /// /// The pass is run after every rotation and can be run again at any time, so "nothing left to do" /// has to be cheap and silent rather than a second round of writes. A pass that re-sealed on every /// call would churn the vault's version numbers and hand every other client a pull per item. /// [Fact] public async Task ASecondPass_FindsNothingLeftToDo() { using var harness = await SyncHarness.CreateAsync(); await harness.First.CreateAsync(SyncHarness.Host("web-01")); await harness.SettleAsync(); harness.Rotate(); (await harness.First.ResealAsync()).Resealed.ShouldBe(1); var again = await harness.First.ResealAsync(); again.Resealed.ShouldBe(0); again.Complete.ShouldBeTrue(); harness.Server.PushCount.ShouldBe(2, "an empty pass must not send a batch at all"); } /// /// An item with an edit waiting to go is left alone by the pass and re-sealed by the push instead. /// Doing it here as well would overwrite the user's queued work with the version the server holds, /// which is the one thing a re-keying pass must never do. /// [Fact] public async Task AQueuedEdit_IsLeftToThePushPathAndStillLandsUnderTheNewKey() { using var harness = await SyncHarness.CreateAsync(); var entityId = await harness.First.CreateAsync(SyncHarness.Host("web-01")); await harness.SettleAsync(); // Queued while the old key was current, and not yet pushed. await harness.First.UpdateAsync(entityId, SyncHarness.Host("web-01", notes: "moved rack")); var superseded = harness.Rotate(); var report = await harness.First.ResealAsync(); report.Deferred.ShouldBe(1); report.Resealed.ShouldBe(0); report.Complete.ShouldBeTrue("a queued change is not something this pass has left undone"); await harness.First.SyncAsync(); var row = harness.Server.Find(entityId).ShouldNotBeNull(); row.Payload.KeyGeneration.ShouldBe(2u); HostCipher.TryOpen(row.Payload, superseded, entityId, row.Version).ShouldBeNull(); // The edit itself survived the re-sealing, which is the half that would be easy to lose. var seen = await harness.Second.SyncAsync(); seen.Pulled.ShouldBeGreaterThan(0); (await harness.Second.FindAsync(entityId)).Secret.Notes.ShouldBe("moved rack"); } /// /// Somebody else writing an item mid-pass is not a failure and not a merge — there is nothing to /// merge, since this pass changes no content. It is counted, left where it is, and picked up by the /// next pass against the version they left behind. That is the whole of the resumability claim. /// [Fact] public async Task AnItemWrittenElsewhereMeanwhile_IsCountedAndPickedUpNextTime() { using var harness = await SyncHarness.CreateAsync(); var entityId = await harness.First.CreateAsync(SyncHarness.Host("web-01")); await harness.SettleAsync(); var superseded = harness.Rotate(); // A third machine that has not heard about the rotation yet: it writes version 2 under the key // it still believes is current. That is the item this pass has to find and move, and sealing it // by hand is the only way to produce one — every client in this harness now holds the new key. var held = harness.Server.Find(entityId).ShouldNotBeNull(); harness.Server.ExternalUpsert( entityId, HostCipher.Seal( SyncHarness.Host("web-01", notes: "renamed elsewhere"), superseded, entityId, keyGeneration: 1, itemVersion: held.Version + 1), held.Fields); var contested = await harness.First.ResealAsync(); contested.Contested.ShouldBe(1); contested.Resealed.ShouldBe(0); contested.Complete.ShouldBeFalse(); // Read what they wrote, then run the pass again: nothing to recover, nothing to decide. await harness.First.SyncAsync(); var second = await harness.First.ResealAsync(); second.Resealed.ShouldBe(1); second.Complete.ShouldBeTrue(); var row = harness.Server.Find(entityId).ShouldNotBeNull(); row.Payload.KeyGeneration.ShouldBe(2u); HostCipher.TryOpen(row.Payload, superseded, entityId, row.Version).ShouldBeNull(); } /// /// Every synced type, not the one the tests happen to use most. The pass is written over the item /// store rather than over the repositories precisely so that a type added later is covered without /// anybody remembering to add it — and this is the test that would notice if it stopped being true. /// [Fact] public async Task EveryKindOfItem_MovesOntoTheNewKey() { using var harness = await SyncHarness.CreateAsync(); await harness.First.CreateAsync(SyncHarness.Host("web-01")); await harness.First.CreateKeyAsync(SyncHarness.Key("deploy")); await harness.First.CreateCredentialAsync(SyncHarness.Credential("registry")); await harness.First.CreateKnownHostAsync(SyncHarness.KnownHost("db.internal")); await harness.SettleAsync(); harness.Rotate(); var report = await harness.First.ResealAsync(); report.Resealed.ShouldBe(4); report.Complete.ShouldBeTrue(); // Read back through the repositories, so this asserts the items are usable and not merely // rewritten: a pass that produced ciphertext nobody could open would pass every count above. (await harness.First.ListAsync()).Unreadable.ShouldBe(0); (await harness.First.ListKeysAsync()).Unreadable.ShouldBe(0); (await harness.First.ListCredentialsAsync()).Unreadable.ShouldBe(0); (await harness.First.ListKnownHostsAsync()).Unreadable.ShouldBe(0); } /// /// A member who has been rotated past and not yet re-wrapped holds the history and no current key. /// They must not attempt this: there is nothing to seal under, and the honest answer is a report of /// zero rather than an exception on a background pass nobody asked for. /// [Fact] public async Task AMachineWithNoCurrentKey_DoesNothingRatherThanFailing() { using var harness = await SyncHarness.CreateAsync(); await harness.First.CreateAsync(SyncHarness.Host("web-01")); await harness.SettleAsync(); // What RefreshVaultsAsync does when the server reports a generation this machine has no grant // for: the vault is marked unreadable and the write target goes with it. harness.First.Keyring.MarkUnreadable(SyncHarness.VaultId); var report = await harness.First.ResealAsync(); report.KeyGeneration.ShouldBe(0u); report.Resealed.ShouldBe(0); harness.Server.PushCount.ShouldBe(1, "nothing was sent"); } }