namespace DodoSSH.Client.Domain.Tests;
///
/// The known-host record, its codec and its merge.
///
///
/// Shorter again than , because three of the four fields are what the item
/// is about rather than content that gets edited. What is specific to this type and worth pinning:
/// that its name is derived rather than stored, that the derived name stays out of the payload and out of
/// equality, that a mangled fingerprint is refused rather than stored to fail comparisons for ever, and that a
/// fingerprint clash is reported with both values — the opposite of what the password merge does.
///
public sealed class KnownHostSecretTests
{
[Fact]
public void APinIsNamedAfterWhatItPins()
{
// Read by the conflict log, which is the only place a person meets one of these. "db.internal:22" is
// something they can match against a host in their list; an item id is not.
Pin().Label.ShouldBe("db.internal:22 (ssh-ed25519)");
Pin(port: 2222).Label.ShouldBe("db.internal:2222 (ssh-ed25519)");
}
[Fact]
public void TwoPinsOfTheSameKey_AreEqual()
{
// The derived label is get-only, so it stays out of the record's equality — which is what makes
// "these are the same pin" a question about the host, port, algorithm and fingerprint alone. The
// reconciler compares secrets this way to recognise its own create coming back.
Pin().ShouldBe(Pin());
Pin(fingerprint: "SHA256:something-else").ShouldNotBe(Pin());
}
[Theory]
[InlineData("", 22, "ssh-ed25519", "SHA256:aaa", "needs the host")]
[InlineData(" ", 22, "ssh-ed25519", "SHA256:aaa", "needs the host")]
[InlineData("db.internal", 0, "ssh-ed25519", "SHA256:aaa", "Port must be between")]
[InlineData("db.internal", 65536, "ssh-ed25519", "SHA256:aaa", "Port must be between")]
[InlineData("db.internal", 22, "", "SHA256:aaa", "needs the key algorithm")]
[InlineData("db.internal", 22, "ssh ed25519", "SHA256:aaa", "needs the key algorithm")]
[InlineData("db.internal", 22, "ssh-ed25519", "", "needs a fingerprint")]
[InlineData("db.internal", 22, "ssh-ed25519", "SHA256:aaa bbb", "needs a fingerprint")]
public void AnInvalidPin_SaysWhatIsWrongWithIt(
string host,
int port,
string algorithm,
string fingerprint,
string expected)
{
var pin = new KnownHostSecret
{
Host = host,
Port = port,
Algorithm = algorithm,
Fingerprint = fingerprint,
};
pin.TryValidate(out var reason).ShouldBeFalse();
reason.ShouldNotBeNull().ShouldContain(expected);
}
[Fact]
public void APinWithSpaceInItsFingerprint_IsRefusedRatherThanStored()
{
// A pasted "SHA256:… comment@host" or a stray newline would compare unequal to the same key on every
// future connection, which the user would read as a permanently changed host key. Refusing it at the
// codec means the bad value never becomes a stored pin.
var mangled = Pin(fingerprint: "SHA256:aaa bbb");
Should.Throw(() => KnownHostSecretCodec.Encode(mangled));
}
[Fact]
public void APin_SurvivesARoundTrip()
{
var pin = Pin(host: "bastion.internal", port: 2222, algorithm: "rsa-sha2-512");
var encoded = KnownHostSecretCodec.Encode(pin);
KnownHostSecretCodec.TryDecode(encoded, out var document).ShouldBeTrue();
document.ShouldNotBeNull();
document.KnownHost.ShouldBe(pin);
document.SchemaVersion.ShouldBe(KnownHostSecretCodec.CurrentSchemaVersion);
document.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void EncodingIsDeterministic()
{
// An unchanged pin must not look like a change to the sync engine, or every pass would push every
// host the user has ever approved.
KnownHostSecretCodec.Encode(Pin()).ShouldBe(KnownHostSecretCodec.Encode(Pin()));
}
[Fact]
public void TheDerivedLabel_IsNotInThePayload()
{
// It is a function of the three fields that are, so writing it would put a value on the wire that a
// reader could disagree with — and a merge could then take the label from one side and the address
// from the other.
var json = System.Text.Encoding.UTF8.GetString(KnownHostSecretCodec.Encode(Pin()));
json.ShouldNotContain("label");
json.ShouldNotContain("(ssh-ed25519)");
}
[Theory]
[InlineData("not json")]
[InlineData("{}")]
[InlineData("""{"schemaVersion":1,"host":"db.internal","port":22,"algorithm":"ssh-ed25519"}""")]
[InlineData("""{"schemaVersion":1,"host":"db.internal","port":22,"fingerprint":"SHA256:aaa"}""")]
[InlineData("""{"schemaVersion":1,"port":22,"algorithm":"ssh-ed25519","fingerprint":"SHA256:aaa"}""")]
[InlineData(
"""{"schemaVersion":1,"host":"db.internal","algorithm":"ssh-ed25519","fingerprint":"SHA256:aaa"}""")]
[InlineData(
"""{"schemaVersion":0,"host":"db.internal","port":22,"algorithm":"ssh-ed25519","fingerprint":"SHA256:a"}""")]
public void APayloadThatIsNotAPin_DoesNotDecode(string json)
{
KnownHostSecretCodec
.TryDecode(System.Text.Encoding.UTF8.GetBytes(json), out var document)
.ShouldBeFalse();
document.ShouldBeNull();
}
[Fact]
public void APinFromANewerClient_IsReadableButNotWritable()
{
// Readable matters here more than for the other types: an unreadable pin means a host looks unvisited
// and the user is asked again. The four fields a pin needs are all present, so a newer schema is
// usable for comparison even though this build must not re-encode it.
var payload = System.Text.Encoding.UTF8.GetBytes(
"""
{"schemaVersion":99,"host":"db.internal","port":22,"algorithm":"ssh-ed25519",
"fingerprint":"SHA256:aaa","approvedBy":"someone using a later build"}
""");
KnownHostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull();
document.IsReadOnly.ShouldBeTrue();
document.KnownHost.Fingerprint.ShouldBe("SHA256:aaa");
}
[Fact]
public void OnlyOneSideReApproving_TakesThatSide()
{
var ancestor = Pin();
var local = ancestor with { Fingerprint = "SHA256:the-rebuilt-server" };
var merged = KnownHostSecretMerge.Merge(ancestor, local, ancestor);
merged.HasConflicts.ShouldBeFalse();
merged.Merged.Fingerprint.ShouldBe("SHA256:the-rebuilt-server");
}
[Fact]
public void BothSidesApprovingADifferentKey_ReportsBothFingerprints()
{
// Deliberately the opposite of the password merge. An operator publishes a fingerprint so that it can
// be compared, and a notice that withheld the value it dropped would leave the user nothing to check.
var ancestor = Pin();
var local = ancestor with { Fingerprint = "SHA256:seen-from-the-laptop" };
var remote = ancestor with { Fingerprint = "SHA256:seen-from-the-desktop" };
var merged = KnownHostSecretMerge.Merge(ancestor, local, remote);
var conflict = merged.Conflicts.ShouldHaveSingleItem();
conflict.Field.ShouldBe(nameof(KnownHostSecret.Fingerprint));
conflict.Kept.ShouldBe("SHA256:seen-from-the-desktop");
conflict.Discarded.ShouldBe("SHA256:seen-from-the-laptop");
// The server's value wins, as it must for every replica to converge on the same answer.
merged.Merged.Fingerprint.ShouldBe("SHA256:seen-from-the-desktop");
}
[Fact]
public void APortClash_IsReportedAsANumberRatherThanAsNothing()
{
// Not reachable from this client — the store never re-addresses a pin — but a payload from elsewhere
// is untrusted input, and a conflict entry with an empty value in it would be a notice about nothing.
var ancestor = Pin();
var local = ancestor with { Port = 2222 };
var remote = ancestor with { Port = 2022 };
var merged = KnownHostSecretMerge.Merge(ancestor, local, remote);
var conflict = merged.Conflicts.ShouldHaveSingleItem();
conflict.Field.ShouldBe(nameof(KnownHostSecret.Port));
conflict.Kept.ShouldBe("2022");
conflict.Discarded.ShouldBe("2222");
}
private static KnownHostSecret Pin(
string host = "db.internal",
int port = 22,
string algorithm = "ssh-ed25519",
string fingerprint = "SHA256:aaa") =>
new()
{
Host = host,
Port = port,
Algorithm = algorithm,
Fingerprint = fingerprint,
};
}