Files
DodoSSH/tests/DodoSSH.Client.Domain.Tests/ValueSemanticsTests.cs

338 lines
14 KiB
C#

using static DodoSSH.Client.Domain.Tests.HostFactory;
namespace DodoSSH.Client.Domain.Tests;
/// <summary>
/// Equality of the collection types, and of the host that holds them.
/// </summary>
/// <remarks>
/// This suite guards a failure that would be invisible rather than loud. If any of these compared by
/// reference, the merge would report every host as changed on every sync pass, two identical edits
/// would register as a conflict, and the engine would push spurious updates forever. Nothing would
/// throw and no test elsewhere would obviously fail — which is exactly why these are asserted here.
/// </remarks>
public sealed class ValueSemanticsTests
{
[Fact]
public void TwoHostsWithEqualContents_AreEqual()
{
var one = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]);
var other = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]);
one.ShouldBe(other);
one.GetHashCode().ShouldBe(other.GetHashCode());
}
[Fact]
public void AHostDifferingOnlyInACollection_IsNotEqual()
{
Host(jumps: [Bastion]).ShouldNotBe(Host(jumps: [Relay]));
Host(options: [("Compression", "yes")]).ShouldNotBe(Host(options: [("Compression", "no")]));
}
// Note on the assertion style below: these call Equals and the operators directly rather than
// going through ShouldBe. Both of these types implement IReadOnlyList, and Shouldly compares
// enumerables element by element — so ShouldBe would pass whatever Equals did, which is the one
// thing this suite exists to check.
[Fact]
public void AJumpChain_ComparesByContentsAndOrder()
{
JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeTrue();
(JumpChain.Create([Bastion, Relay]) == JumpChain.Create([Bastion, Relay])).ShouldBeTrue();
JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Relay, Bastion])).ShouldBeFalse();
JumpChain.Create([Bastion]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeFalse();
JumpChain.Create([]).Equals(JumpChain.Empty).ShouldBeTrue();
JumpChain.Create([Bastion]).Equals(null).ShouldBeFalse();
}
[Fact]
public void AJumpChain_HashesByContents()
{
JumpChain.Create([Bastion, Relay]).GetHashCode()
.ShouldBe(JumpChain.Create([Bastion, Relay]).GetHashCode());
}
[Fact]
public void AJumpChain_ComparesEqualAcrossTheSpanAndSequenceFactories()
{
Guid[] hops = [Bastion, Relay];
JumpChain.Create(hops.AsSpan()).Equals(JumpChain.Create(hops.AsEnumerable())).ShouldBeTrue();
}
[Fact]
public void Directives_CompareIgnoringNameCaseAndInputOrder()
{
// Both halves matter. Case, because a merge picks whichever spelling it saw first and two
// clients must still agree. Order, because the collection canonicalises and a user typing
// the same two directives in the other order has not changed anything.
var one = HostOptions.Create([new HostOption("Compression", "yes"), new HostOption("Port", "22")]);
var other = HostOptions.Create([new HostOption("port", "22"), new HostOption("compression", "yes")]);
one.Equals(other).ShouldBeTrue();
(one == other).ShouldBeTrue();
one.GetHashCode().ShouldBe(other.GetHashCode());
}
[Fact]
public void Directives_CompareValuesCaseSensitively()
{
// Keywords are case-insensitive in SSH; values are not. "yes" and "YES" happen to mean the
// same to sshd, but this layer must not decide that for every directive that exists.
HostOptions.Create([new HostOption("Compression", "yes")])
.Equals(HostOptions.Create([new HostOption("Compression", "YES")]))
.ShouldBeFalse();
}
[Fact]
public void Directives_CompareUnequalWhenOneSideHasMore()
{
var one = HostOptions.Create([new HostOption("Compression", "yes")]);
var other = HostOptions.Create(
[new HostOption("Compression", "yes"), new HostOption("Port", "22")]);
one.Equals(other).ShouldBeFalse();
HostOptions.Empty.Equals(one).ShouldBeFalse();
one.Equals(null).ShouldBeFalse();
}
[Fact]
public void Directives_AreHeldInNameOrder()
{
var options = HostOptions.Create(
[
new HostOption("ServerAliveInterval", "30"),
new HostOption("Compression", "yes"),
]);
options[0].Name.ShouldBe("Compression");
options[1].Name.ShouldBe("ServerAliveInterval");
}
[Fact]
public void ARepeatedDirectiveName_IsRefused()
{
// A repeated keyword has no merge key, so M1 cannot represent it. Refusing is the honest
// answer; silently keeping one of the two would lose data without saying so.
var duplicate = new[]
{
new HostOption("Compression", "yes"),
new HostOption("compression", "no"),
};
HostOptions.TryCreate(duplicate, out _, out var error).ShouldBeFalse();
error.ShouldNotBeNull();
error.Contains("more than once", StringComparison.Ordinal).ShouldBeTrue();
Should.Throw<ArgumentException>(() => HostOptions.Create(duplicate));
}
[Fact]
public void ABlankDirectiveName_IsRefused()
{
HostOptions.TryCreate([new HostOption(" ", "x")], out _, out _).ShouldBeFalse();
}
[Fact]
public void AHostBuiltWithWith_KeepsCollectionEquality()
{
// `with` copies the collection references, so this would pass even under reference equality.
// It is here because the merge builds its result with an object initialiser rather than
// `with`, and both paths have to agree.
var host = Host(options: [("Compression", "yes")]);
var copy = host with { Notes = "changed" };
copy.Options.Equals(host.Options).ShouldBeTrue();
(copy with { Notes = host.Notes }).ShouldBe(host);
}
[Fact]
public void ATagSet_ComparesByContentsAndNotByOrder()
{
// The half that differs from a jump chain, and the reason it is a separate type. A route reordered
// is a different route; a tag list reordered is the same host, so two users who tapped the same two
// chips in opposite orders must produce one value and nothing to push.
TagSet.Create([Pci, EuWest]).Equals(TagSet.Create([EuWest, Pci])).ShouldBeTrue();
(TagSet.Create([Pci, EuWest]) == TagSet.Create([EuWest, Pci])).ShouldBeTrue();
TagSet.Create([Pci, EuWest]).GetHashCode()
.ShouldBe(TagSet.Create([EuWest, Pci]).GetHashCode());
TagSet.Create([Pci]).Equals(TagSet.Create([EuWest])).ShouldBeFalse();
TagSet.Create([Pci]).Equals(TagSet.Create([Pci, EuWest])).ShouldBeFalse();
TagSet.Create([]).Equals(TagSet.Empty).ShouldBeTrue();
TagSet.Create([Pci]).Equals(null).ShouldBeFalse();
}
[Fact]
public void ATagSet_CollapsesARepeatedTag()
{
// A repeat arrives from a payload some other client wrote. Left alone it would compare unequal to
// the same set written once, and the engine would push the host as changed on every pass for ever.
TagSet.Create([Pci, Pci]).Equals(TagSet.Create([Pci])).ShouldBeTrue();
TagSet.Create([Pci, Pci]).Count.ShouldBe(1);
}
[Fact]
public void ATagSet_MapsToItsOwnIdsSoAKeyedMergeIsASetMerge()
{
// The value repeats the key on purpose: no key can then hold two different values, so the only
// disagreement a keyed merge can report is one side adding what the other removed.
var map = TagSet.Create([Pci, EuWest]).ToIdMap();
map.Keys.Order().ShouldBe(new[] { Pci, EuWest }.Order());
map[Pci].ShouldBe(Pci);
map[EuWest].ShouldBe(EuWest);
}
[Fact]
public void APinnedPathList_ComparesByContentsAndOrder()
{
// The half that differs from a tag set and matches a jump chain: a pinned-path list is what a
// shortcut menu draws top to bottom, so reordering it is a change, not a no-op.
PinnedPathList.Create(["/var/log", "/var/www/app"])
.Equals(PinnedPathList.Create(["/var/log", "/var/www/app"]))
.ShouldBeTrue();
(PinnedPathList.Create(["/var/log", "/var/www/app"])
== PinnedPathList.Create(["/var/log", "/var/www/app"])).ShouldBeTrue();
PinnedPathList.Create(["/var/log", "/var/www/app"])
.Equals(PinnedPathList.Create(["/var/www/app", "/var/log"]))
.ShouldBeFalse();
PinnedPathList.Create(["/var/log"])
.Equals(PinnedPathList.Create(["/var/log", "/var/www/app"]))
.ShouldBeFalse();
PinnedPathList.Create([]).Equals(PinnedPathList.Empty).ShouldBeTrue();
PinnedPathList.Create(["/var/log"]).Equals(null).ShouldBeFalse();
}
[Fact]
public void APinnedPathList_HashesByContentsAndOrder()
{
PinnedPathList.Create(["/var/log", "/var/www/app"]).GetHashCode()
.ShouldBe(PinnedPathList.Create(["/var/log", "/var/www/app"]).GetHashCode());
}
[Fact]
public void APinnedPathList_ComparesPathsOrdinally()
{
// A remote path is meaningful only to the far end, and that end may run a case-sensitive
// filesystem — so folding case here would be a decision this client has no business making.
PinnedPathList.Create(["/var/log"]).Equals(PinnedPathList.Create(["/VAR/LOG"])).ShouldBeFalse();
}
[Fact]
public void APinnedPathList_CollapsesARepeatedPathKeepingTheFirstOccurrence()
{
// A repeat arrives from a payload some other client wrote, or from the same path pinned twice on
// one machine. Left alone it would compare unequal to the same list written once, and the engine
// would push the host as changed on every pass for ever.
PinnedPathList.Create(["/var/log", "/var/log"]).Equals(PinnedPathList.Create(["/var/log"]))
.ShouldBeTrue();
PinnedPathList.Create(["/var/log", "/var/log"]).Count.ShouldBe(1);
// The first occurrence survives rather than the last, so a later repeat cannot silently move an
// earlier entry to a new position in the list.
PinnedPathList.Create(["/var/log", "/var/www/app", "/var/log"])[0].ShouldBe("/var/log");
PinnedPathList.Create(["/var/log", "/var/www/app", "/var/log"]).Count.ShouldBe(2);
}
[Fact]
public void APinnedPathList_MapsToItsOwnPathsInListOrder()
{
// The value repeats the key on purpose, exactly as TagSet.ToIdMap's does: no key can then hold two
// different values, so the only disagreement a keyed merge can report is one side adding what the
// other removed. Unlike TagSet, the map is built in list order rather than a canonical one, which
// is what lets the merge reproduce "base order, then additions".
var map = PinnedPathList.Create(["/var/log", "/var/www/app"]).ToPathMap();
map.Keys.ShouldBe(new[] { "/var/log", "/var/www/app" });
map["/var/log"].ShouldBe("/var/log");
map["/var/www/app"].ShouldBe("/var/www/app");
}
[Fact]
public void TryValidate_RejectsWhatCannotBeStored()
{
Host(label: "").TryValidate(out _).ShouldBeFalse();
Host(hostname: " ").TryValidate(out _).ShouldBeFalse();
Host(port: 0).TryValidate(out _).ShouldBeFalse();
Host(port: 65536).TryValidate(out _).ShouldBeFalse();
Host(jumps: [Guid.Empty]).TryValidate(out _).ShouldBeFalse();
Host(sshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
Host(credentialId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
Host(tags: [Guid.Empty]).TryValidate(out _).ShouldBeFalse();
Host(pinnedPaths: [" "]).TryValidate(out _).ShouldBeFalse();
Host(pinnedPaths: ["/has\0nul"]).TryValidate(out _).ShouldBeFalse();
// Null is "take the group's port", not an absent one, and it has to be storable — it is the whole
// of what inheritance stores.
Host(port: null).TryValidate(out _).ShouldBeTrue();
Host().TryValidate(out _).ShouldBeTrue();
// A relative path is exactly as valid as an absolute one: it resolves against the account's home,
// which this client never needs to know.
Host(pinnedPaths: ["relative/within/home"]).TryValidate(out _).ShouldBeTrue();
}
[Fact]
public void TryValidate_BoundsThePinnedPaths()
{
Host(pinnedPaths: [new string('a', HostSecret.MaxPinnedPathLength)])
.TryValidate(out _).ShouldBeTrue();
Host(pinnedPaths: [new string('a', HostSecret.MaxPinnedPathLength + 1)])
.TryValidate(out var tooLong).ShouldBeFalse();
tooLong.ShouldNotBeNull().ShouldContain("longer than");
var atLimit = Enumerable.Range(0, HostSecret.MaxPinnedPaths)
.Select(i => $"/path/{i}")
.ToArray();
Host(pinnedPaths: atLimit).TryValidate(out _).ShouldBeTrue();
var overLimit = Enumerable.Range(0, HostSecret.MaxPinnedPaths + 1)
.Select(i => $"/path/{i}")
.ToArray();
Host(pinnedPaths: overLimit).TryValidate(out var tooMany).ShouldBeFalse();
tooMany.ShouldNotBeNull().ShouldContain("cannot pin more than");
}
[Fact]
public void AHostGivesOneAnswerAboutAuthentication_NotTwo()
{
// The same failure the key-or-credential rule catches, in the direction inheritance opened: a host
// that names a key and also says "ask me for a password" has answered one question twice, and the
// interface, the connect path and the user would each be free to pick a different answer.
var both = Host(sshKeyId: DeployKey, asksForPassword: true);
both.TryValidate(out var reason).ShouldBeFalse();
reason.ShouldNotBeNull().ShouldContain("not both");
Host(asksForPassword: true).TryValidate(out _).ShouldBeTrue();
Host(sshKeyId: DeployKey).TryValidate(out _).ShouldBeTrue();
}
[Fact]
public void AHostAuthenticatesOneWay_NotTwo()
{
// SSH would happily try a key and fall back to a password, and a host that named both would leave
// "how does this authenticate?" without a single answer — so the interface, the connect path and the
// user would each be free to guess differently. Refused at the type instead.
var both = Host(sshKeyId: DeployKey, credentialId: Guid.CreateVersion7());
both.TryValidate(out var reason).ShouldBeFalse();
reason.ShouldNotBeNull().ShouldContain("not both");
Host(sshKeyId: DeployKey).TryValidate(out _).ShouldBeTrue();
Host(credentialId: Guid.CreateVersion7()).TryValidate(out _).ShouldBeTrue();
}
}