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