namespace DodoSSH.Client.Session.Tests; /// /// Where the profile goes. /// /// /// A short suite for something that looks trivial and is not. The cache is a SQLite file written by one /// process, and the design assumes each machine has its own — the outbox holds changes only this machine /// has made. Two machines sharing one file through a cloud sync client corrupts it, so a roaming or /// synced directory is a correctness problem rather than a matter of taste. /// public sealed class ClientPathsTests { [Fact] public void TheCacheLivesInsideTheProfileDirectory() { var paths = new ClientPaths(Path.Combine("C:", "somewhere", "DodoSSH")); Path.GetDirectoryName(paths.CacheFile).ShouldBe(paths.DataDirectory); Path.GetFileName(paths.CacheFile).ShouldBe("cache.db"); } [Fact] public void TheDefaultDirectoryIsAbsoluteAndNamed() { var paths = ClientPaths.Default; Path.IsPathFullyQualified(paths.DataDirectory).ShouldBeTrue(paths.DataDirectory); // OrdinalIgnoreCase, and the casing is the point rather than an oversight. ClientPaths spells the // folder DodoSSH on Windows and dodossh on Unix on purpose — one follows the platform's title-cased // convention, the other the lower-case dotfile one. The Ordinal "odoSSH" this used to look for was // clever enough to survive either spelling of the leading D and still only ever matched Windows, // which went unnoticed for exactly as long as nothing ran the suite anywhere else. paths.DataDirectory.Contains("dodossh", StringComparison.OrdinalIgnoreCase) .ShouldBeTrue($"'{paths.DataDirectory}' should be identifiable as ours"); } [Fact] public void ResolvingTheDefault_CreatesNothing() { // Read during startup diagnostics and by tests. A side effect here would mean merely asking where // the cache would go creates a directory. var paths = new ClientPaths( Path.Combine(Path.GetTempPath(), $"dodossh-paths-{Guid.CreateVersion7():N}")); Directory.Exists(paths.DataDirectory).ShouldBeFalse(); paths.EnsureCreated(); try { Directory.Exists(paths.DataDirectory).ShouldBeTrue(); // Idempotent, because startup runs it every launch. paths.EnsureCreated(); } finally { Directory.Delete(paths.DataDirectory, recursive: true); } } /// /// The nightly channel keeps its own profile, and everything else keeps the existing one. /// /// /// Both halves are load-bearing and they fail in opposite directions. A nightly sharing the release /// build's directory would migrate the cache schema of a database the release build then opens — the /// two are installed at once by design, so that is an ordinary Tuesday. And the release channel's /// directory moving even slightly would orphan every existing install's cache, outbox and device key: /// the application would start, find nothing, and ask for a server. See ADR 0013 decision 9. /// [Theory] [InlineData("release")] [InlineData("")] [InlineData(null)] [InlineData("something nobody wrote")] public void EveryChannelButTheNightlyKeepsTheExistingProfile(string? channel) { var resolved = ClientPaths.ForChannel(channel).DataDirectory; // string.Equals with an explicit comparison rather than ShouldBe, here and below: these are paths // built from the same constants, so the comparison that means anything is the exact one. string.Equals(resolved, ClientPaths.Default.DataDirectory, StringComparison.Ordinal) .ShouldBeTrue($"'{channel}' resolved to '{resolved}' rather than to the default profile"); } [Fact] public void TheNightlyProfileIsASiblingOfTheReleaseOne() { var release = ClientPaths.Default.DataDirectory; var nightly = ClientPaths.ForChannel("nightly").DataDirectory; string.Equals(nightly, release, StringComparison.Ordinal) .ShouldBeFalse($"the nightly and the release build would share '{release}'"); // A sibling rather than a child, so neither install's uninstaller or reset can reach the other's. string.Equals( Path.GetDirectoryName(nightly), Path.GetDirectoryName(release), StringComparison.Ordinal) .ShouldBeTrue($"'{nightly}' should sit beside '{release}'"); // And still identifiably ours, on either platform's spelling — the same property the default is // checked for above, for the same reason. nightly.Contains("dodossh", StringComparison.OrdinalIgnoreCase) .ShouldBeTrue($"'{nightly}' should be identifiable as ours"); // The files follow the directory. A nightly writing the release build's cache or device key is the // whole failure this separation exists to prevent, so it is asserted rather than inferred. var separated = ClientPaths.ForChannel("nightly"); string.Equals(separated.CacheFile, ClientPaths.Default.CacheFile, StringComparison.Ordinal) .ShouldBeFalse("the two builds would write one cache.db"); string.Equals(separated.DeviceKeyFile, ClientPaths.Default.DeviceKeyFile, StringComparison.Ordinal) .ShouldBeFalse("the two builds would share one device key"); } [Fact] public void OnWindowsItIsTheLocalProfileAndNotTheRoamingOne() { // %APPDATA% roams in a domain environment, which would sync one machine's SQLite cache to another // and corrupt it. %LOCALAPPDATA% does not. if (!OperatingSystem.IsWindows()) { Assert.Skip("Windows-only path convention."); } var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); ClientPaths.Default.DataDirectory.ShouldStartWith(local); // Guard against the two happening to be equal on some configuration, which would make the // assertion above meaningless. if (!string.Equals(local, roaming, StringComparison.OrdinalIgnoreCase)) { ClientPaths.Default.DataDirectory.ShouldNotStartWith(roaming); } } }