using DodoSSH.Contracts; using DodoSSH.Crypto; namespace DodoSSH.Client.Storage.Tests; /// /// The cache as it is actually deployed: a file on disk. /// /// /// Every other suite here uses an in-memory database because it is faster and isolated. That leaves the /// production path — , a real migration against a file that does /// not exist yet, and data surviving the process that wrote it — untested, which is exactly the shape of /// bug that only appears on a user's first launch. /// public sealed class FileBackedCacheTests : IDisposable { private readonly string directory = Path.Combine(Path.GetTempPath(), $"dodossh-cache-{Guid.CreateVersion7():N}"); /// public void Dispose() { if (Directory.Exists(directory)) { Directory.Delete(directory, recursive: true); } } [Fact] public async Task AMigrationCreatesTheFileAndTheDataOutlivesTheFactory() { Directory.CreateDirectory(directory); var path = Path.Combine(directory, "cache.db"); var material = Material(); using (var first = ClientCacheFactory.ForFile(path)) { await first.MigrateAsync(Token); File.Exists(path).ShouldBeTrue("the migration should have created the database"); await new UnlockStore(first, TimeProvider.System).SaveAsync(material, Token); } // A second factory over the same file, as a later launch of the application is. using var second = ClientCacheFactory.ForFile(path); // Migrating again is what every launch does, and it has to be a no-op rather than an error. await second.MigrateAsync(Token); var read = await new UnlockStore(second, TimeProvider.System).ReadAsync(Token); read.ShouldNotBeNull(); read.UserId.ShouldBe(material.UserId); read.WrappedPrivateKey.ShouldBe(material.WrappedPrivateKey); read.KdfParameters.Salt.ShouldBe(material.KdfParameters.Salt); } [Fact] public async Task AMissingDirectory_FailsClearlyRatherThanSilently() { // The application creates the profile directory before opening the cache. If that order were ever // reversed, this is the error it would produce — worth pinning so the failure stays diagnosable // instead of turning into an empty vault. using var factory = ClientCacheFactory.ForFile( Path.Combine(directory, "missing", "cache.db")); await Should.ThrowAsync( async () => await factory.MigrateAsync(Token)); } private static CancellationToken Token => TestContext.Current.CancellationToken; private static StoredUnlockMaterial Material() => new( "https://dodossh.example", Guid.CreateVersion7(), "https://idp.example", "alice", "alice@example.com", "Alice", KeyGeneration: 1, WrappedPrivateKey: [1, 2, 3, 4], new KdfParameters("argon2id", [5, 6, 7, 8], 262144, 4, 1), DateTimeOffset.FromUnixTimeSeconds(1_750_000_000)); }