namespace DodoSSH.Client.Ssh.Tests;
///
/// What makes two pins the same pin, and the in-memory store that answers on those terms.
///
///
///
/// No container and no vault. is shared by every
/// , so the identity rules are asserted here once, at the layer that defines
/// them — and is what the rest of this suite runs against, so its own
/// behaviour has to be right or every test above it is testing something the application does not do.
///
///
/// The vault-backed store that actually ships has the same rules asserted against a real cache in
/// DodoSSH.Client.Session.Tests. The duplication is deliberate: two implementations of one interface,
/// and a store that quietly disagreed with the other about which host a pin belongs to would make this
/// suite's coverage of the connect path meaningless.
///
///
public sealed class KnownHostStoreTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task AnUnvisitedHost_HasNoPin()
{
var store = new InMemoryKnownHostStore();
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
}
[Fact]
public async Task APinAnswersForOneHostPortAndAlgorithm()
{
// A server legitimately offers several host keys and may negotiate a different one next time, so the
// algorithm is part of what was approved. A pin that answered for all of them would accept a key
// nobody checked.
var store = new InMemoryKnownHostStore();
await store.TrustAsync(Presented(), Token);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
(await store.FindAsync("db.internal", 22, "rsa-sha2-512", Token)).ShouldBeNull();
(await store.FindAsync("db.internal", 2222, "ssh-ed25519", Token)).ShouldBeNull();
(await store.FindAsync("other.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
}
[Fact]
public async Task AHostNameIsMatchedWithoutRegardToCase()
{
// DNS is case-insensitive, so these are one machine and must be one pin.
var store = new InMemoryKnownHostStore();
await store.TrustAsync(Presented(host: "DB.internal"), Token);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
(await store.FindAsync("db.INTERNAL", 22, "SSH-ED25519", Token)).ShouldBe("SHA256:approved");
}
[Fact]
public async Task ReApproving_ReplacesTheFingerprint()
{
var store = new InMemoryKnownHostStore();
await store.TrustAsync(Presented(), Token);
await store.TrustAsync(Presented(fingerprint: "SHA256:rebuilt"), Token);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:rebuilt");
}
[Fact]
public async Task ForgettingAHost_TakesEveryAlgorithmAndNothingElse()
{
// The decision being withdrawn is about the machine, not about one of the keys it offers — and a pin
// left behind would keep refusing a connection the user believes they have already fixed.
var store = new InMemoryKnownHostStore();
await store.TrustAsync(Presented(algorithm: "ssh-ed25519"), Token);
await store.TrustAsync(Presented(algorithm: "rsa-sha2-512"), Token);
await store.TrustAsync(Presented(port: 2222), Token);
await store.TrustAsync(Presented(host: "other.internal"), Token);
(await store.ForgetAsync("db.internal", 22, Token)).ShouldBe(2);
(await store.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
(await store.FindAsync("db.internal", 22, "rsa-sha2-512", Token)).ShouldBeNull();
// A different port is a different endpoint, and a different host is obviously untouched.
(await store.FindAsync("db.internal", 2222, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
(await store.FindAsync("other.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
}
[Fact]
public async Task ForgettingAHostThatWasNeverApproved_SaysNothingWasRemoved()
{
var store = new InMemoryKnownHostStore();
(await store.ForgetAsync("db.internal", 22, Token)).ShouldBe(0);
}
[Fact]
public async Task ForgettingIsCaseInsensitiveToo()
{
// The lookup and the withdrawal have to agree about identity, or a pin could be found and not
// forgotten — which is the worst of the two, because the user would be told the trust was gone.
var store = new InMemoryKnownHostStore();
await store.TrustAsync(Presented(host: "DB.internal"), Token);
(await store.ForgetAsync("db.internal", 22, Token)).ShouldBe(1);
(await store.FindAsync("DB.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
}
[Fact]
public void AnIdentityIsMadeOfAllThreeParts()
{
// Stated directly, because every store keys on this string and a format that dropped the port or the
// algorithm would silently merge pins that are not the same pin.
KnownHostIdentity.For("db.internal", 22, "ssh-ed25519").ShouldBe("db.internal:22/ssh-ed25519");
// Asserted through the comparer rather than with ShouldNotBe, because the comparer is what every
// store actually keys on — a difference the default string comparison sees but this one does not
// would still collapse two pins into one.
KnownHostIdentity.Comparer.Equals(
KnownHostIdentity.For("db.internal", 22, "ssh-ed25519"),
KnownHostIdentity.For("db.internal", 2222, "ssh-ed25519")).ShouldBeFalse();
KnownHostIdentity.Comparer.Equals(
KnownHostIdentity.For("DB.internal", 22, "ssh-ed25519"),
KnownHostIdentity.For("db.internal", 22, "ssh-ed25519")).ShouldBeTrue();
}
private static HostKeyPresentation Presented(
string host = "db.internal",
int port = 22,
string algorithm = "ssh-ed25519",
string fingerprint = "SHA256:approved") =>
new(host, port, algorithm, fingerprint);
}
/// An that runs its callback on the thread that reported.
///
/// System.Progress<T> posts to a captured synchronisation context, or to the thread pool when
/// there is none — which is what a test has here — so a list it appended to would be asserted on before it
/// had been written. The same reason the shell does not use it either; see VaultViewModel.ReporterFor.
///
internal sealed class DelegateProgress(Action report) : IProgress
{
public void Report(T value) => report(value);
}