using DodoSSH.Contracts; using DodoSSH.Crypto; using Microsoft.EntityFrameworkCore; using static DodoSSH.Client.Storage.Tests.CacheHarness; namespace DodoSSH.Client.Storage.Tests; /// /// The item mirror, the vault list, the sync cursor, and the conflict log. /// public sealed class CacheStoreTests : IAsyncLifetime { private CacheHarness harness = null!; /// public async ValueTask InitializeAsync() => harness = await CreateAsync(); /// public ValueTask DisposeAsync() { harness.Dispose(); return ValueTask.CompletedTask; } // ---- Items ---- [Fact] public async Task AnItem_RoundTripsItsCiphertextByteForByte() { // Not "equivalent" — identical. The AAD binds the row, so re-encrypting locally would work but // would throw away the ability to notice the server handing back bytes it should not have. var entityId = Guid.CreateVersion7(); var item = Item(entityId, version: 4, changeSequence: 17, seed: 3); await harness.Items.SaveAsync(item, Token); var read = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token); read.ShouldNotBeNull(); read.Version.ShouldBe(4); read.ChangeSequence.ShouldBe(17); read.Payload.ShouldNotBeNull(); read.Payload.Envelope.ShouldBe(item.Payload!.Envelope); read.Payload.WrappedDataKey.ShouldBe(item.Payload.WrappedDataKey); read.Payload.DataKeyId.ShouldBe(item.Payload.DataKeyId); read.Payload.KeyGeneration.ShouldBe(item.Payload.KeyGeneration); read.Payload.AadVersion.ShouldBe(item.Payload.AadVersion); } [Fact] public async Task ThePlaintextFields_RoundTripAndAreNotReadableInTheDatabase() { // The server has to hold a relay-enabled host's address in the clear because it resolves it. // This machine already holds the key that opens the payload, so leaving the address readable in // a file that ends up in a backup buys nothing. var entityId = Guid.CreateVersion7(); var fields = new SyncPlaintextFields( RelayEnabled: true, Hostname: "bastion.internal", Port: 2222); await harness.Items.SaveAsync(Item(entityId, fields: fields), Token); var read = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token); read!.Fields.ShouldBe(fields); var stored = await ReadRawFieldsAsync(entityId); stored.ShouldNotBeNull(); System.Text.Encoding.UTF8.GetString(stored) .Contains("bastion.internal", StringComparison.Ordinal) .ShouldBeFalse("the hostname is stored in the clear"); } [Fact] public async Task ACacheRecord_CannotBeMovedToAnotherRow() { // Why the record is bound to its own row rather than only to the user. Swapping two rows would // otherwise point one host's connection at another host's address. var mine = Guid.CreateVersion7(); var other = Guid.CreateVersion7(); var sealedFields = harness.Protector.Protect( CryptoSpec.AadResourceType.Host, mine, PlaintextFieldsCodec.Encode(new SyncPlaintextFields(true, "mine.internal", 22))); harness.Protector .TryUnprotect(CryptoSpec.AadResourceType.Host, other, sealedFields) .ShouldBeNull(); harness.Protector .TryUnprotect(CryptoSpec.AadResourceType.Credential, mine, sealedFields) .ShouldBeNull(); harness.Protector .TryUnprotect(CryptoSpec.AadResourceType.Host, mine, sealedFields) .ShouldNotBeNull(); } [Fact] public async Task ARecordSealedByAnotherIdentity_DoesNotOpen() { // Another *identity*, not another passphrase, and the distinction is the point. The cache key now // derives from the secret bundle, so changing a passphrase deliberately keeps the cache readable — // UserSecretBundleTests.TheLocalCacheKey_SurvivesAPassphraseChange pins that. What must still be // unreadable is another user's cache, and that is what a second harness is. var entityId = Guid.CreateVersion7(); using var stranger = await CreateAsync(); var sealedFields = stranger.Protector.Protect( CryptoSpec.AadResourceType.Host, entityId, [1, 2, 3]); harness.Protector .TryUnprotect(CryptoSpec.AadResourceType.Host, entityId, sealedFields) .ShouldBeNull(); } [Fact] public async Task SavingTwice_ReplacesRatherThanDuplicates() { var entityId = Guid.CreateVersion7(); await harness.Items.SaveAsync(Item(entityId, version: 1, seed: 1), Token); await harness.Items.SaveAsync(Item(entityId, version: 2, seed: 9), Token); var items = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, false, Token); items.ShouldHaveSingleItem().Version.ShouldBe(2); } [Fact] public async Task ATombstone_IsHiddenFromTheListButStillFindable() { // The interface must not show a deleted host. The sync engine must still be able to tell a // deleted item from one it has never seen — a row that simply vanished is indistinguishable // from the latter, and would silently reappear. var entityId = Guid.CreateVersion7(); await harness.Items.SaveAsync(Item(entityId, version: 1), Token); await harness.Items.SaveAsync(Item(entityId, version: 2, deleted: true), Token); (await harness.Items.ListAsync(VaultId, SyncEntityType.Host, false, Token)).ShouldBeEmpty(); var withDeleted = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token); withDeleted.ShouldHaveSingleItem().IsDeleted.ShouldBeTrue(); var found = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token); found.ShouldNotBeNull(); found.IsDeleted.ShouldBeTrue(); found.Payload.ShouldBeNull(); } [Fact] public async Task CollectingTombstones_LeavesLiveItemsAlone() { var live = Guid.CreateVersion7(); var dead = Guid.CreateVersion7(); await harness.Items.SaveAsync(Item(live, changeSequence: 1), Token); await harness.Items.SaveAsync(Item(dead, changeSequence: 2, deleted: true), Token); var cutoff = DateTimeOffset.FromUnixTimeSeconds(1_750_000_100); var collected = await harness.Items.CollectTombstonesAsync(VaultId, cutoff, Token); collected.ShouldBe(1); var remaining = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token); remaining.ShouldHaveSingleItem().EntityId.ShouldBe(live); } // ---- Vaults ---- [Fact] public async Task ReplacingTheVaultList_AddsUpdatesAndRemoves() { var keep = Guid.CreateVersion7(); var drop = Guid.CreateVersion7(); await harness.Vaults.ReplaceAllAsync( [Vault(keep, "Personal", 1), Vault(drop, "Old team", 1)], Token); await harness.Vaults.ReplaceAllAsync([Vault(keep, "Renamed", 2)], Token); var vaults = await harness.Vaults.ListAsync(Token); var only = vaults.ShouldHaveSingleItem(); only.VaultId.ShouldBe(keep); only.Name.ShouldBe("Renamed"); only.KeyGeneration.ShouldBe(2u); (await harness.Vaults.FindAsync(drop, Token)).ShouldBeNull(); } [Fact] public async Task AVaultsWrappedKey_IsCachedSoAnOfflineLaunchCanDecrypt() { // Without this, an offline start could unlock the identity bundle and still not open a single // item. var vaultId = Guid.CreateVersion7(); byte[] wrapped = [9, 9, 9, 9]; await harness.Vaults.ReplaceAllAsync( [Vault(vaultId, "Personal", 1) with { WrappedVaultKey = wrapped }], Token); var read = await harness.Vaults.FindAsync(vaultId, Token); read!.WrappedVaultKey.ShouldBe(wrapped); } // ---- Unlock material ---- [Fact] public async Task TheUnlockMaterial_SurvivesTheContextThatWroteIt() { // The offline unlock story, asserted rather than assumed: a fresh store over the same database // reads back the salt and the wrapped bundle with no network involved. var material = Material(); await harness.Unlock.SaveAsync(material, Token); var reader = new UnlockStore(harness.Factory, TimeProvider.System); var read = await reader.ReadAsync(Token); read.ShouldNotBeNull(); read.UserId.ShouldBe(material.UserId); read.WrappedPrivateKey.ShouldBe(material.WrappedPrivateKey); read.KdfParameters.Salt.ShouldBe(material.KdfParameters.Salt); read.KdfParameters.MemoryKibibytes.ShouldBe(material.KdfParameters.MemoryKibibytes); read.KdfParameters.Passes.ShouldBe(material.KdfParameters.Passes); } [Fact] public async Task SavingTheUnlockMaterialTwice_UpdatesTheSingleRow() { await harness.Unlock.SaveAsync(Material(), Token); await harness.Unlock.SaveAsync(Material() with { Email = "changed@example.com" }, Token); var read = await harness.Unlock.ReadAsync(Token); read!.Email.ShouldBe("changed@example.com"); (await CountUnlockRowsAsync()).ShouldBe(1); } [Fact] public async Task AnotherUsersMaterial_IsRefusedRatherThanMixedIn() { // Adopting it would offer an unlock prompt whose passphrase can never work, and would mix one // user's items into another's vault list. await harness.Unlock.SaveAsync(Material(), Token); var other = Material() with { UserId = Guid.CreateVersion7() }; await Should.ThrowAsync( async () => await harness.Unlock.SaveAsync(other, Token)); } // ---- Sync state ---- [Fact] public async Task AnUnknownVault_ReadsAsStartingFromTheBeginning() { // Not an error. A null cursor is exactly right for a vault this client has not synced, and is // also the recovery path for a cache that had to be discarded. var state = await harness.SyncState.ReadAsync(Guid.CreateVersion7(), Token); state.Cursor.ShouldBeNull(); state.KeyGeneration.ShouldBe(0u); } [Fact] public async Task TheCursor_IsStoredVerbatim() { // Opaque and integrity-tagged. A client that adjusted one could ask to resume from a position // the server never granted; storing it untouched is the only correct handling. const string Cursor = "v1.aGVsbG8gd29ybGQ.c2lnbmF0dXJl"; await harness.SyncState.SaveAsync(new StoredSyncState(VaultId, Cursor, 3), Token); var read = await harness.SyncState.ReadAsync(VaultId, Token); read.Cursor.ShouldBe(Cursor); read.KeyGeneration.ShouldBe(3u); } [Fact] public async Task ResettingAVault_DropsItsItemsButKeepsTheOutbox() { // Local unpushed changes are the only copy of the user's work. Clearing them along with the // cache would turn a recoverable cache problem into lost work. var entityId = Guid.CreateVersion7(); await harness.Items.SaveAsync(Item(entityId), Token); await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token); await harness.SyncState.SaveAsync(new StoredSyncState(VaultId, "cursor", 1), Token); await harness.SyncState.ResetAsync(VaultId, Token); (await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token)).ShouldBeEmpty(); (await harness.SyncState.ReadAsync(VaultId, Token)).Cursor.ShouldBeNull(); (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem(); } // ---- Conflicts ---- [Fact] public async Task AConflictDetail_RoundTripsAndIsSealedAtRest() { // This is the one place the cache holds decrypted vault content on purpose: the value a merge // displaced. It has to be readable to be useful, and it is as sensitive as the item it came // from. var entityId = Guid.CreateVersion7(); var detail = System.Text.Encoding.UTF8.GetBytes("""{"field":"Notes","discarded":"my secret"}"""); var id = await harness.Conflicts.RecordAsync( VaultId, SyncEntityType.Host, entityId, ConflictKind.FieldOverridden, detail, Token); var listed = (await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldHaveSingleItem(); listed.Id.ShouldBe(id); listed.Kind.ShouldBe(ConflictKind.FieldOverridden); listed.Detail.ShouldBe(detail); var raw = await ReadRawConflictDetailAsync(id); System.Text.Encoding.UTF8.GetString(raw!) .Contains("my secret", StringComparison.Ordinal) .ShouldBeFalse("the discarded value is stored in the clear"); } [Fact] public async Task AnAcknowledgedConflict_LeavesTheListButKeepsTheValue() { // Someone who dismisses a warning and realises a minute later that they wanted the other value // should still be able to get it. var id = await harness.Conflicts.RecordAsync( VaultId, SyncEntityType.Host, Guid.CreateVersion7(), ConflictKind.FieldOverridden, new byte[] { 1, 2, 3 }, Token); (await harness.Conflicts.AcknowledgeAsync(id, Token)).ShouldBeTrue(); (await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldBeEmpty(); var all = (await harness.Conflicts.ListAsync(VaultId, true, Token)).ShouldHaveSingleItem(); all.Detail.ShouldBe(new byte[] { 1, 2, 3 }); } [Fact] public async Task AnUnacknowledgedConflict_CannotBeDiscarded() { var id = await harness.Conflicts.RecordAsync( VaultId, SyncEntityType.Host, Guid.CreateVersion7(), ConflictKind.Undecryptable, new byte[] { 1 }, Token); (await harness.Conflicts.DiscardAsync(id, Token)).ShouldBeFalse(); await harness.Conflicts.AcknowledgeAsync(id, Token); (await harness.Conflicts.DiscardAsync(id, Token)).ShouldBeTrue(); } // ---- Helpers ---- private static CancellationToken Token => TestContext.Current.CancellationToken; private static StoredVault Vault(Guid vaultId, string name, uint keyGeneration) => new(vaultId, name, IsPersonal: true, TeamId: null, keyGeneration, Permissions: 31, WrappedVaultKey: [1, 2, 3], RekeyRequired: false); private static StoredUnlockMaterial Material() => new( "https://dodossh.example", UserId, "https://idp.example", "alice", "alice@example.com", "Alice", KeyGeneration: 1, WrappedPrivateKey: [4, 5, 6, 7], new KdfParameters("argon2id", [8, 9, 10, 11], 262144, 4, 1), DateTimeOffset.FromUnixTimeSeconds(1_750_000_000)); private async Task ReadRawFieldsAsync(Guid entityId) { var context = harness.Factory.CreateDbContext(); await using var scope = context.ConfigureAwait(false); return await context.Set() .Where(row => row.EntityId == entityId) .Select(row => row.ProtectedFields) .SingleAsync(Token); } private async Task ReadRawConflictDetailAsync(Guid id) { var context = harness.Factory.CreateDbContext(); await using var scope = context.ConfigureAwait(false); return await context.Set() .Where(row => row.Id == id) .Select(row => row.Detail) .SingleAsync(Token); } private async Task CountUnlockRowsAsync() { var context = harness.Factory.CreateDbContext(); await using var scope = context.ConfigureAwait(false); return await context.Set().CountAsync(Token); } }