Public Access
Merge origin/main into claude/spelunk-tunnel
Port forwarding took host schema version 8 first, so the Spelunk route moves to 9 and is written after the forwards. A host may carry both.
This commit is contained in:
commit
689ba6c1bf
44 files changed
+3187
-75
No files matched your search
@@ -21,11 +21,29 @@ internal static class HostFactory
|
||||
/// <summary>A second tag id, for the tests about two people tagging one host.</summary>
|
||||
internal static Guid EuWest { get; } = Guid.Parse("0192f0c8-9999-7c3d-8e4f-5a6b7c8d9e09");
|
||||
|
||||
/// <summary>A forward to the database, as the host would carry it.</summary>
|
||||
internal static PortForwardRule Postgres { get; } = new()
|
||||
{
|
||||
Id = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a"),
|
||||
Kind = PortForwardKind.Local,
|
||||
BindPort = 5432,
|
||||
TargetHost = "db.internal",
|
||||
TargetPort = 5432,
|
||||
};
|
||||
|
||||
/// <summary>A SOCKS forward, for the tests about two forwards on one host.</summary>
|
||||
internal static PortForwardRule Socks { get; } = new()
|
||||
{
|
||||
Id = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
|
||||
Kind = PortForwardKind.Dynamic,
|
||||
BindPort = 1080,
|
||||
};
|
||||
|
||||
/// <summary>A credential holding a Spelunk API key, for the hosts reached through Spelunk.</summary>
|
||||
internal static Guid SpelunkKey { get; } = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a");
|
||||
internal static Guid SpelunkKey { get; } = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");
|
||||
|
||||
/// <summary>A server's id in Spelunk.</summary>
|
||||
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
|
||||
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d");
|
||||
|
||||
/// <summary>A route to <see cref="SpelunkServer"/>, with <see cref="SpelunkKey"/>.</summary>
|
||||
internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com") =>
|
||||
@@ -51,6 +69,7 @@ internal static class HostFactory
|
||||
Guid? groupId = null,
|
||||
Guid[]? tags = null,
|
||||
string[]? pinnedPaths = null,
|
||||
PortForwardRule[]? portForwards = null,
|
||||
SpelunkRoute? spelunk = null) =>
|
||||
new()
|
||||
{
|
||||
@@ -70,6 +89,7 @@ internal static class HostFactory
|
||||
GroupId = groupId,
|
||||
TagIds = tags is null ? TagSet.Empty : TagSet.Create(tags),
|
||||
PinnedPaths = pinnedPaths is null ? PinnedPathList.Empty : PinnedPathList.Create(pinnedPaths),
|
||||
PortForwards = portForwards is null ? PortForwardList.Empty : PortForwardList.Create(portForwards),
|
||||
Spelunk = spelunk,
|
||||
};
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ public sealed class HostSecretCodecTests
|
||||
/// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
|
||||
/// carry a key or a credential and never both, and neither may sit beside <c>AsksForPassword</c>. This
|
||||
/// one carries the credential, because that is the newer of the two, plus a group, a pair of tags, a
|
||||
/// pair of pinned paths and a Spelunk route — which are orthogonal to the binding and are what make this
|
||||
/// host reach the highest schema version a valid host can. The route and the relay are exclusive in the
|
||||
/// same way the bindings are, so this carries the newer of those two as well; the relay's flag has its
|
||||
/// own tests.
|
||||
/// pair of pinned paths, a pair of forwards and a Spelunk route — which are orthogonal to the binding and
|
||||
/// are what make this host reach the highest schema version a valid host can. The route and the relay are
|
||||
/// exclusive in the same way the bindings are, so this carries the newer of those two as well; the relay's
|
||||
/// flag has its own tests.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void AFullHost_RoundTrips()
|
||||
@@ -40,6 +40,7 @@ public sealed class HostSecretCodecTests
|
||||
groupId: Production,
|
||||
tags: [Pci, EuWest],
|
||||
pinnedPaths: ["/var/www/app", "/var/log"],
|
||||
portForwards: [Postgres, Socks],
|
||||
spelunk: ThroughSpelunk());
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
@@ -59,6 +59,7 @@ public sealed class HostSecretMergeTests
|
||||
GroupId = Production,
|
||||
TagIds = TagSet.Create([Pci]),
|
||||
PinnedPaths = PinnedPathList.Create(["/var/www/app"]),
|
||||
PortForwards = PortForwardList.Create([Postgres]),
|
||||
};
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, ancestor);
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
using System.Text;
|
||||
using static DodoSSH.Client.Domain.Tests.HostFactory;
|
||||
|
||||
namespace DodoSSH.Client.Domain.Tests;
|
||||
|
||||
/// <summary>Port forwards as a host carries them: stored, validated, compared and merged.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ public sealed class SpelunkRouteTests
|
||||
|
||||
Encoding.UTF8.GetString(bytes).ShouldBe(
|
||||
"""
|
||||
{"schemaVersion":8,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b","apiKeyCredentialId":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a"}}
|
||||
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"}}
|
||||
""");
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ public sealed class SpelunkRouteTests
|
||||
// The other reading — no route — would turn a damaged Spelunk host into one dialled directly.
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":8,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com"}}
|
||||
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com"}}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
|
||||
@@ -156,10 +156,10 @@ public sealed class SpelunkRouteTests
|
||||
public void ARouteWrittenByANewerClient_IsReadOnlyHere()
|
||||
{
|
||||
// The rule every version before this one relies on, checked from the far side: a build that knows
|
||||
// version 8 reads a version 9 host, shows it, and refuses to re-encode what it cannot represent.
|
||||
// version 9 reads a version 10 host, shows it, and refuses to re-encode what it cannot represent.
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b","apiKeyCredentialId":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a"},"somethingNewer":true}
|
||||
{"schemaVersion":10,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"},"somethingNewer":true}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
Reference in new issue
Block a user