using System.Text;
using static DodoSSH.Client.Domain.Tests.HostFactory;
namespace DodoSSH.Client.Domain.Tests;
///
/// A host reached through Spelunk: the route's own rules, how it is stored, and how it merges.
///
///
/// 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.
///
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.IsReadOnly.ShouldBeFalse();
}
[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 10 reads a version 11 host, shows it, and refuses to re-encode what it cannot represent.
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":11,"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());
}
// ---- Signing in through Spelunk ----
[Fact]
public void AHostSpelunkSignsInTo_IsValid_WithNoLoginOfItsOwn()
{
// No username, no key, no credential, no typed password: Spelunk names the account and makes the key.
var host = Host(username: null, spelunk: ThroughSpelunk(signIn: true));
host.TryValidate(out var reason).ShouldBeTrue(reason);
host.SignsInThroughSpelunk.ShouldBeTrue();
}
[Fact]
public void AHostSpelunkSignsInTo_KeepsALoginItStillCarries()
{
// Left alone rather than refused: it is the host's answer for the day the box is unticked, and a
// merge of one machine ticking it with another binding a key must still decode.
Host(sshKeyId: Guid.CreateVersion7(), spelunk: ThroughSpelunk(signIn: true))
.TryValidate(out var reason)
.ShouldBeTrue(reason);
}
[Fact]
public void AHostReachedThroughSpelunkWithoutSigningIn_DoesNotSignIn()
{
Host(spelunk: ThroughSpelunk()).SignsInThroughSpelunk.ShouldBeFalse();
Host().SignsInThroughSpelunk.ShouldBeFalse();
}
[Fact]
public void ASignIn_RoundTrips_AtTheVersionThatIntroducedIt()
{
var host = Host(username: null, spelunk: ThroughSpelunk(signIn: true));
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
document.ShouldNotBeNull();
document.Host.ShouldBe(host);
document.Host.Spelunk.ShouldNotBeNull().SignInThroughSpelunk.ShouldBeTrue();
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSignInSchemaVersion);
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
document.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void ASignIn_IsWrittenInsideTheRoute_AndOnlyWhenOn()
{
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, spelunk: ThroughSpelunk(signIn: true)));
Encoding.UTF8.GetString(bytes).ShouldBe(
"""
{"schemaVersion":10,"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","signIn":true}}
""");
}
[Fact]
public void ARouteWrittenBeforeSignIn_DecodesAsNotSigningIn_AndStaysEditable()
{
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":9,"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"}}
""");
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull().Host.Spelunk.ShouldBe(ThroughSpelunk());
document.Host.SignsInThroughSpelunk.ShouldBeFalse();
document.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void ASignInWrittenAsFalse_ReadsAsTheRouteWithout()
{
// One state, one spelling — the merge would otherwise see a change nobody made.
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","signIn":false}}
""");
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull().Host.Spelunk.ShouldBe(ThroughSpelunk());
}
[Fact]
public void TickingSignIn_MergesAsPartOfTheOneRoute()
{
var ancestor = Host(spelunk: ThroughSpelunk());
var local = ancestor with { Spelunk = ThroughSpelunk(signIn: true) };
var remote = ancestor with { Spelunk = ThroughSpelunk() with { ServerId = Guid.CreateVersion7() } };
// Alone, the tick arrives.
HostSecretMerge.Merge(ancestor, local, ancestor).Merged.ShouldBe(local);
// Beside a different edit to the route, the route is still one value: one side wins whole, with the
// other reported, rather than a sign-in to a server nobody chose it for.
var result = HostSecretMerge.Merge(ancestor, local, remote);
result.Merged.Spelunk.ShouldBe(remote.Spelunk);
result.Conflicts.ShouldHaveSingleItem().Field.ShouldBe(nameof(HostSecret.Spelunk));
}
// ---- 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));
}
}