using System.Text; using static DodoSSH.Client.Domain.Tests.HostFactory; namespace DodoSSH.Client.Domain.Tests; /// Port forwards as a host carries them: stored, validated, compared and merged. public sealed class PortForwardTests { // ---- Encoding ---- [Fact] public void AHostWithForwards_IsWrittenAtTheirVersion_AndOnlyThen() { HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(portForwards: [Postgres])), out var with) .ShouldBeTrue(); HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(portForwards: [])), out var without) .ShouldBeTrue(); with.ShouldNotBeNull().SchemaVersion.ShouldBe(HostSecretCodec.PortForwardsSchemaVersion); without.ShouldNotBeNull().SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion); } [Fact] public void Forwards_AreWrittenLast_InListOrder_WithTheKindAsItsNumber() { // Last, so every older field keeps its bytes. The kind is a number so that renaming an enum member // can never change what another machine reads. var bytes = HostSecretCodec.Encode(Host(username: null, portForwards: [Socks, Postgres])); Encoding.UTF8.GetString(bytes).ShouldBe( """ {"schemaVersion":8,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"portForwards":[{"id":"0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b","kind":2,"bindPort":1080},{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","kind":0,"bindPort":5432,"targetHost":"db.internal","targetPort":5432}]} """); } [Fact] public void APayloadFromBeforeForwards_DecodesAsCarryingNone() { var payload = Encoding.UTF8.GetBytes( """ {"schemaVersion":7,"label":"prod-db","hostname":"db.internal","port":22,"pinnedPaths":["/var/log"]} """); HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); document.ShouldNotBeNull().Host.PortForwards.ShouldBe(PortForwardList.Empty); document.IsReadOnly.ShouldBeFalse(); } [Theory] [InlineData("""{"kind":0,"bindPort":5432,"targetHost":"db","targetPort":5432}""")] [InlineData("""{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","bindPort":5432,"targetHost":"db","targetPort":5432}""")] [InlineData("""{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","kind":0,"targetHost":"db","targetPort":5432}""")] [InlineData("""{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","kind":7,"bindPort":5432,"targetHost":"db","targetPort":5432}""")] [InlineData("""{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","kind":0,"bindPort":5432}""")] [InlineData("""{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","kind":2,"bindPort":1080,"targetHost":"db","targetPort":5432}""")] [InlineData("null")] public void AForwardMissingOrContradictingWhatItNeeds_FailsTheDecode(string forward) { // Nothing is filled in with a default: an invented id or kind is a forward the next save writes back // as though somebody had asked for it. var payload = Encoding.UTF8.GetBytes( $$"""{"schemaVersion":8,"label":"prod-db","hostname":"db.internal","port":22,"portForwards":[{{forward}}]}"""); HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse(); } [Fact] public void APayloadRepeatingAForwardId_DecodesKeepingTheFirst() { var payload = Encoding.UTF8.GetBytes( """ {"schemaVersion":8,"label":"prod-db","hostname":"db.internal","port":22,"portForwards":[{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","kind":0,"bindPort":5432,"targetHost":"db.internal","targetPort":5432},{"id":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a","kind":2,"bindPort":1080}]} """); HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); document.ShouldNotBeNull().Host.PortForwards.ShouldBe(PortForwardList.Create([Postgres])); } // ---- Validation ---- [Fact] public void AHostCarryingTooManyForwards_IsRefused() { var forwards = Enumerable.Range(0, HostSecret.MaxPortForwards + 1) .Select(i => Postgres with { Id = Guid.CreateVersion7(), BindPort = 10000 + i }) .ToArray(); Host(portForwards: forwards).TryValidate(out var reason).ShouldBeFalse(); reason.ShouldBe($"A host cannot carry more than {HostSecret.MaxPortForwards} port forwards."); } [Fact] public void TwoForwardsOnOnePort_AreStorable() { // What two machines each adding one produces, and a merge whose result could not be written back // would strand the host. The second listener is what fails, when it starts. var clash = Postgres with { Id = Guid.CreateVersion7(), TargetHost = "replica.internal" }; Host(portForwards: [Postgres, clash]).TryValidate(out _).ShouldBeTrue(); } [Theory] [InlineData(PortForwardKind.Local, 0, "db", 5432, "A forward's listening port must be between 1 and 65535, not 0.")] [InlineData(PortForwardKind.Local, 5432, " ", 5432, "A forward needs a host to forward to.")] [InlineData(PortForwardKind.Local, 5432, "db internal", 5432, "A forward's target cannot contain spaces or control characters.")] [InlineData(PortForwardKind.Remote, 8080, "localhost", null, "A forward needs a port to forward to.")] [InlineData(PortForwardKind.Remote, 8080, "localhost", 70000, "A forward's target port must be between 1 and 65535, not 70000.")] [InlineData(PortForwardKind.Dynamic, 1080, "db", null, "A dynamic forward has no target: each connection through it names its own.")] public void AnInvalidForward_IsRefusedWithItsReason( PortForwardKind kind, int bindPort, string? targetHost, int? targetPort, string expected) { var forward = new PortForwardRule { Id = Guid.CreateVersion7(), Kind = kind, BindPort = bindPort, TargetHost = targetHost, TargetPort = targetPort, }; forward.TryValidate(out var reason).ShouldBeFalse(); reason.ShouldBe(expected); Host(portForwards: [forward]).TryValidate(out _).ShouldBeFalse(); } [Fact] public void AnEmptyId_IsRefused() { (Postgres with { Id = Guid.Empty }).TryValidate(out var reason).ShouldBeFalse(); reason.ShouldBe("A port forward cannot have an empty id."); } [Theory] [InlineData(PortForwardKind.Local, "-L 5432:db.internal:5432")] [InlineData(PortForwardKind.Remote, "-R 5432:db.internal:5432")] public void Notation_IsOpenSshs(PortForwardKind kind, string expected) { (Postgres with { Kind = kind }).Notation.ShouldBe(expected); Socks.Notation.ShouldBe("-D 1080"); } // ---- Equality ---- [Fact] public void AForwardList_ComparesByContentsAndOrder() { PortForwardList.Create([Postgres, Socks]).ShouldBe(PortForwardList.Create([Postgres, Socks])); (PortForwardList.Create([Postgres, Socks]) == PortForwardList.Create([Postgres, Socks])).ShouldBeTrue(); PortForwardList.Create([Postgres, Socks]).ShouldNotBe(PortForwardList.Create([Socks, Postgres])); Host(portForwards: [Postgres]).ShouldBe(Host(portForwards: [Postgres])); } // ---- Merge ---- [Fact] public void TwoMachinesEachAddingAForward_BothKeepTheirs() { var ancestor = Host(); var result = HostSecretMerge.Merge( ancestor, ancestor with { PortForwards = PortForwardList.Create([Postgres]) }, ancestor with { PortForwards = PortForwardList.Create([Socks]) }); result.Merged.PortForwards.ShouldBe(PortForwardList.Create([Postgres, Socks])); result.HasConflicts.ShouldBeFalse(); } [Fact] public void OneForwardEditedDifferentlyOnBothSides_TakesTheServersAndRecordsTheOther() { var ancestor = Host(portForwards: [Postgres]); var local = Postgres with { BindPort = 15432 }; var remote = Postgres with { TargetHost = "replica.internal" }; var result = HostSecretMerge.Merge( ancestor, ancestor with { PortForwards = PortForwardList.Create([local]) }, ancestor with { PortForwards = PortForwardList.Create([remote]) }); result.Merged.PortForwards.ShouldBe(PortForwardList.Create([remote])); var conflict = result.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe($"PortForwards[{Postgres.Id}]"); conflict.Kept.ShouldBe("-L 5432:replica.internal:5432"); conflict.Discarded.ShouldBe("-L 15432:db.internal:5432"); conflict.DiscardedWasRemoval.ShouldBeFalse(); } [Fact] public void AnEditBeatsARemoval_AndTheRemovalIsRecorded() { var ancestor = Host(portForwards: [Postgres]); var edited = Postgres with { BindPort = 15432 }; var result = HostSecretMerge.Merge( ancestor, ancestor with { PortForwards = PortForwardList.Empty }, ancestor with { PortForwards = PortForwardList.Create([edited]) }); result.Merged.PortForwards.ShouldBe(PortForwardList.Create([edited])); result.Conflicts.ShouldHaveSingleItem().DiscardedWasRemoval.ShouldBeTrue(); } [Fact] public void ARemovalOnOneSideOnly_IsTaken() { var ancestor = Host(portForwards: [Postgres, Socks]); var result = HostSecretMerge.Merge( ancestor, ancestor with { PortForwards = PortForwardList.Create([Socks]) }, ancestor); result.Merged.PortForwards.ShouldBe(PortForwardList.Create([Socks])); result.HasConflicts.ShouldBeFalse(); } }