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)); } }