Files
DodoSSH/tests/DodoSSH.Client.Domain.Tests/SpelunkRouteTests.cs
T
laurensandClaude Opus 5.5 ef365f462e Spelunk: sign each device in through the browser instead of keeping Spelunk API keys in the vault
A Spelunk API key reaches one organisation, so a server elsewhere - oc-linux in Orange Climate - was
refused before it reached the agent. Each device now signs in to a Spelunk once, under Settings ->
Security -> Spelunk or from a Spelunk host's editor: the OAuth authorization code flow with PKCE for a
native app (RFC 8252), with Spelunk as the authorization server. The browser opens Spelunk's
/api/app-sign-in/authorize with a loopback redirect (the app's own scheme on Android), the person signs
in there however they normally do and allows "DodoSSH on <device>", and the code is redeemed at
/api/app-sign-in/token for an app session token. That token opens terminals in every organisation its
person is in, with the role they have on each server at the time; tunnel tickets send no organisation.

The sign-in stays on the device: sealed in the local cache under the LocalCacheKey (AAD resource type
18, bound to its row), never synced, wiped with the cache, and only ever sent to the Spelunk it came
from. A 401 from Spelunk forgets it; SIGN OUT ends it there and here, and so does signing out of
DodoSSH. Before every Spelunk connection DodoSSH asks its own identity provider whether its sign-in
still stands (a forced refresh, at most once a minute), and an account it refuses is signed out of
DodoSSH and of every Spelunk on the device.

A route names no key any more: Spelunk hosts are written at host schema 11, which an older build shows
read-only and asks to be updated for, and a payload that still names a key credential decodes with the
id skipped. The SpelunkKey item type is gone: SyncEntityType 14, ChangeEntityType 14 and AAD type 17
are retired, the server drops spelunk_key and its change-log rows, and the client cache drops its
cached items, outbox entries and conflicts of that type. A link still never starts a sign-in: from a
Spelunk this device is not signed in to it opens the editor, which offers to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:26:25 +02:00

302 lines
12 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("api")]
public void ARouteMissingAPart_IsRefused(string missing)
{
var route = missing switch
{
"server" => ThroughSpelunk() with { ServerId = 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.SpelunkSignedInDeviceSchemaVersion);
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":11,"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"}}
""");
}
[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 11 reads a version 12 host, shows it, and refuses to re-encode what it cannot represent.
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":12,"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.SpelunkSignedInDeviceSchemaVersion);
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":11,"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","signIn":true}}
""");
}
[Fact]
public void ARouteWrittenWithAnApiKey_DecodesWithoutIt_AsNotSigningIn_AndStaysEditable()
{
// The API key a route used to name is an unknown member now, skipped: the host is reached as whoever
// this device signed in to Spelunk as, and saving it writes it at version 11 without the key.
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));
}
}