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);
}
}
}