Public Access
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.
200 lines
7.7 KiB
C#
200 lines
7.7 KiB
C#
using System.Text;
|
|
using static DodoSSH.Client.Domain.Tests.HostFactory;
|
|
|
|
namespace DodoSSH.Client.Domain.Tests;
|
|
|
|
/// <summary>
|
|
/// A host reached through Spelunk: the route's own rules, how it is stored, and how it merges.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The weight is on the compatibility rules, because a Spelunk host misread by some build is a host dialled
|
|
/// directly at an address that exists only to name its key — the one misreading that does something the
|
|
/// user never asked for.
|
|
/// </remarks>
|
|
public sealed class SpelunkRouteTests
|
|
{
|
|
// ---- The API address ----
|
|
|
|
[Theory]
|
|
[InlineData("https://api.spelunk.example.com", "https://api.spelunk.example.com")]
|
|
[InlineData(" https://API.Spelunk.Example.com/ ", "https://api.spelunk.example.com")]
|
|
[InlineData("https://api.spelunk.example.com:8443/", "https://api.spelunk.example.com:8443")]
|
|
[InlineData("https://example.com/spelunk/", "https://example.com/spelunk")]
|
|
[InlineData("http://localhost:5080", "http://localhost:5080")]
|
|
[InlineData("http://127.0.0.1:5080/", "http://127.0.0.1:5080")]
|
|
public void AnApiAddress_IsStoredInOneForm(string typed, string stored)
|
|
{
|
|
// One form, so the host a link finds is the host that was saved for it however either was typed.
|
|
SpelunkRoute.TryNormalizeApiBase(typed, out var normalized, out _).ShouldBeTrue();
|
|
|
|
normalized.ShouldBe(stored);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("api.spelunk.example.com")]
|
|
[InlineData("ftp://api.spelunk.example.com")]
|
|
[InlineData("javascript:alert(1)")]
|
|
[InlineData("https://user:pass@api.spelunk.example.com")]
|
|
[InlineData("https://api.spelunk.example.com/?x=1")]
|
|
[InlineData("https://api.spelunk.example.com/#top")]
|
|
public void AnAddressThatIsNotASpelunkApi_IsRefused(string typed)
|
|
{
|
|
SpelunkRoute.TryNormalizeApiBase(typed, out _, out var reason).ShouldBeFalse();
|
|
|
|
reason.ShouldNotBeNullOrWhiteSpace();
|
|
}
|
|
|
|
[Fact]
|
|
public void PlainHttp_IsRefusedAnywhereButThisMachine()
|
|
{
|
|
// The key is a bearer token sent with every tunnel; in clear across a network it is somebody else's.
|
|
SpelunkRoute.TryNormalizeApiBase("http://api.spelunk.example.com", out _, out var reason)
|
|
.ShouldBeFalse();
|
|
|
|
reason.ShouldNotBeNull().ShouldContain("https");
|
|
}
|
|
|
|
// ---- The host ----
|
|
|
|
[Fact]
|
|
public void AHostReachedThroughSpelunk_IsValid()
|
|
{
|
|
Host(spelunk: ThroughSpelunk()).TryValidate(out var reason).ShouldBeTrue(reason);
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostCannotTakeTheRelayAndSpelunkBoth()
|
|
{
|
|
Host(relayEnabled: true, spelunk: ThroughSpelunk()).TryValidate(out var reason).ShouldBeFalse();
|
|
|
|
reason.ShouldNotBeNull().ShouldContain("not both");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("server")]
|
|
[InlineData("key")]
|
|
[InlineData("api")]
|
|
public void ARouteMissingAPart_IsRefused(string missing)
|
|
{
|
|
var route = missing switch
|
|
{
|
|
"server" => ThroughSpelunk() with { ServerId = Guid.Empty },
|
|
"key" => ThroughSpelunk() with { ApiKeyCredentialId = Guid.Empty },
|
|
_ => ThroughSpelunk() with { ApiBase = "https://api.spelunk.example.com/" },
|
|
};
|
|
|
|
Host(spelunk: route).TryValidate(out _).ShouldBeFalse();
|
|
}
|
|
|
|
// ---- The payload ----
|
|
|
|
[Fact]
|
|
public void AHostReachedThroughSpelunk_RoundTrips_AtTheVersionThatIntroducedIt()
|
|
{
|
|
var host = Host(spelunk: ThroughSpelunk(), credentialId: Guid.CreateVersion7());
|
|
|
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
|
|
|
document.ShouldNotBeNull();
|
|
document.Host.ShouldBe(host);
|
|
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSchemaVersion);
|
|
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostWithoutARoute_EncodesExactlyAsBefore()
|
|
{
|
|
// Not using the feature is not using the feature: no key in the JSON, and no version bump, so every
|
|
// existing host keeps its bytes and the first sync after the upgrade pushes nothing.
|
|
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null));
|
|
|
|
Encoding.UTF8.GetString(bytes).ShouldBe(
|
|
"""
|
|
{"schemaVersion":1,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false}
|
|
""");
|
|
}
|
|
|
|
[Fact]
|
|
public void TheRoute_IsWrittenLastAndWhole()
|
|
{
|
|
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, spelunk: ThroughSpelunk()));
|
|
|
|
Encoding.UTF8.GetString(bytes).ShouldBe(
|
|
"""
|
|
{"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"}}
|
|
""");
|
|
}
|
|
|
|
[Fact]
|
|
public void AnOlderPayload_StillDecodes_AsAHostThatIsDialled()
|
|
{
|
|
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.Spelunk.ShouldBeNull();
|
|
document.IsReadOnly.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void AHalfWrittenRoute_MakesTheHostUnreadable_RatherThanDialled()
|
|
{
|
|
// The other reading — no route — would turn a damaged Spelunk host into one dialled directly.
|
|
var payload = Encoding.UTF8.GetBytes(
|
|
"""
|
|
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com"}}
|
|
""");
|
|
|
|
HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void ARouteWrittenByANewerClient_IsReadOnlyHere()
|
|
{
|
|
// The rule every version before this one relies on, checked from the far side: a build that knows
|
|
// version 9 reads a version 10 host, shows it, and refuses to re-encode what it cannot represent.
|
|
var payload = Encoding.UTF8.GetBytes(
|
|
"""
|
|
{"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();
|
|
|
|
document.ShouldNotBeNull().IsReadOnly.ShouldBeTrue();
|
|
document.Host.Spelunk.ShouldBe(ThroughSpelunk());
|
|
}
|
|
|
|
// ---- The merge ----
|
|
|
|
[Fact]
|
|
public void TheRoute_IsRoutedThroughTheMerge()
|
|
{
|
|
var ancestor = Host();
|
|
var local = ancestor with { Spelunk = ThroughSpelunk() };
|
|
|
|
var result = HostSecretMerge.Merge(ancestor, local, ancestor);
|
|
|
|
result.Merged.ShouldBe(local);
|
|
result.HasConflicts.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void TwoDifferentRoutes_MergeAsOneValue_AndTheDiscardedOneIsReported()
|
|
{
|
|
// Whole, not per part: one side's API address with the other side's server id is a route nobody
|
|
// chose, to a server that may not exist on that Spelunk.
|
|
var ancestor = Host();
|
|
var local = ancestor with { Spelunk = ThroughSpelunk("https://spelunk.local.example") };
|
|
var remote = ancestor with { Spelunk = ThroughSpelunk() with { ServerId = Guid.CreateVersion7() } };
|
|
|
|
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
|
|
|
result.Merged.Spelunk.ShouldBe(remote.Spelunk);
|
|
result.Conflicts.ShouldHaveSingleItem().Field.ShouldBe(nameof(HostSecret.Spelunk));
|
|
}
|
|
}
|