using DodoSSH.Contracts; using DodoSSH.Crypto; namespace DodoSSH.Client.Storage.Tests; /// /// A migrated, unlocked cache for one test. /// /// /// Each harness gets its own in-memory database, so tests cannot interfere and can run in parallel. /// The Argon2id cost is deliberately far below the shipped profile — 8 MiB and one pass rather than /// 256 MiB and four. The stretching is what makes a stolen wrap expensive to attack, and none of these /// tests attack one; paying 320 ms per test to prove nothing would only encourage sharing state /// between them. /// internal sealed class CacheHarness : IDisposable { /// /// Fixed rather than "now", because a bundle's encoding includes its creation timestamp and the cache /// key derives from that encoding. Nothing here depends on the value; it only has to be a constant. /// private static readonly DateTimeOffset IdentityCreatedAt = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); private CacheHarness(ClientCacheFactory factory, LocalCacheProtector protector) { Factory = factory; Protector = protector; Items = new ItemStore(factory, protector); Outbox = new OutboxStore(factory, protector, TimeProvider.System); Vaults = new VaultStore(factory, TimeProvider.System); Unlock = new UnlockStore(factory, TimeProvider.System); SyncState = new SyncStateStore(factory); Conflicts = new ConflictStore(factory, protector, TimeProvider.System); } internal static Guid VaultId { get; } = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0f"); internal static Guid UserId { get; } = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0f"); internal ClientCacheFactory Factory { get; } internal LocalCacheProtector Protector { get; } internal ItemStore Items { get; } internal OutboxStore Outbox { get; } internal VaultStore Vaults { get; } internal UnlockStore Unlock { get; } internal SyncStateStore SyncState { get; } internal ConflictStore Conflicts { get; } /// /// Each harness gets a freshly generated identity, so two of them are two different users and their /// cache keys differ. There is no passphrase here at all any more: the cache key derives from the /// bundle, so a passphrase is not part of the question this harness sets up. /// internal static async Task CreateAsync() { var factory = ClientCacheFactory.ForMemory($"cache-{Guid.CreateVersion7():N}"); try { await factory.MigrateAsync(TestContext.Current.CancellationToken); // Disposed immediately: the protector copies the derived key, so the identity itself is not // needed once it has answered. using var identity = UserSecretBundle.Create(IdentityCreatedAt); return new CacheHarness(factory, LocalCacheProtector.From(identity)); } catch { factory.Dispose(); throw; } } /// public void Dispose() { Protector.Dispose(); Factory.Dispose(); } // ---- Builders ---- internal static EncryptedPayload Payload(byte seed = 1, uint keyGeneration = 1) => new( Envelope: [seed, (byte)(seed + 1), (byte)(seed + 2)], WrappedDataKey: [(byte)(seed + 10), (byte)(seed + 11)], DataKeyId: Guid.Parse($"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e{seed:x2}"), KeyGeneration: keyGeneration, AadVersion: CryptoSpec.CurrentAadVersion); internal static StoredItem Item( Guid entityId, int version = 1, long changeSequence = 1, byte seed = 1, bool deleted = false, SyncPlaintextFields? fields = null) => new( VaultId, SyncEntityType.Host, entityId, version, changeSequence, deleted ? null : Payload(seed), deleted ? null : fields ?? new SyncPlaintextFields(), deleted, DateTimeOffset.FromUnixTimeSeconds(1_750_000_000 + changeSequence)); internal static QueuedChange Change( Guid entityId, SyncOperation operation = SyncOperation.Upsert, int? expectedVersion = null, byte seed = 1, StoredAncestor? ancestor = null, SyncPlaintextFields? fields = null) => new( VaultId, SyncEntityType.Host, entityId, operation, expectedVersion, operation == SyncOperation.Delete ? null : Payload(seed), operation == SyncOperation.Delete ? null : fields ?? new SyncPlaintextFields(), ancestor); }