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.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":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"}} """); } [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":8,"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 8 reads a version 9 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} """); 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)); } }