using System.Text; using static DodoSSH.Client.Domain.Tests.HostFactory; namespace DodoSSH.Client.Domain.Tests; /// /// The payload encoding. /// /// /// Two properties carry weight here. Determinism, because the sync engine compares to decide whether /// to push, and a codec that produced different bytes for the same host would make every pass look /// like a change. And failing closed on anything malformed, because these bytes are decrypted inside /// a sync pass where an exception would strand every item queued behind the bad one. /// public sealed class HostSecretCodecTests { /// /// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may /// carry a key or a credential and never both, and neither may sit beside AsksForPassword. This /// one carries the credential, because that is the newer of the two, plus a group and a pair of tags — /// which are orthogonal to the binding and are what make this host reach the highest schema version a /// valid host can. /// [Fact] public void AFullHost_RoundTrips() { var credentialId = Guid.Parse("0192f0c8-5555-7c3d-8e4f-5a6b7c8d9e05"); var host = Host( label: "prod-db", hostname: "db.internal", port: 2222, username: "deploy", notes: "primary replica", jumps: [Bastion, Relay], options: [("ServerAliveInterval", "30"), ("Compression", "yes")], relayEnabled: true, credentialId: credentialId, groupId: Production, tags: [Pci, EuWest]); HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue(); document.ShouldNotBeNull(); document.Host.ShouldBe(host); document.Host.CredentialId.ShouldBe(credentialId); document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion); document.IsReadOnly.ShouldBeFalse(); } // ---- The schema version is content-dependent ---- [Fact] public void AHostWithNoKey_IsStillWrittenAtVersionOne() { // The compatibility rule, and the reason it is worth having. The version is what makes an older // client refuse to edit an item, so stamping the newest one on every write would mean upgrading one // machine and renaming one host made that host uneditable everywhere else. A host that uses nothing // new stays readable and writable by the older build. HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host()), out var document).ShouldBeTrue(); document.ShouldNotBeNull(); document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion); } [Fact] public void AHostThatBindsAKey_IsWrittenAtTheVersionThatIntroducedIt() { HostSecretCodec .TryDecode(HostSecretCodec.Encode(Host(sshKeyId: DeployKey)), out var document) .ShouldBeTrue(); document.ShouldNotBeNull(); document.SchemaVersion.ShouldBe(HostSecretCodec.SshKeyIdSchemaVersion); document.Host.SshKeyId.ShouldBe(DeployKey); } [Fact] public void AHostThatBindsACredential_IsWrittenAtTheVersionThatIntroducedIt() { // Each binding earns its own version, so a host using only the older one is not dragged forward onto // a version older clients refuse to edit. var credentialId = Guid.CreateVersion7(); HostSecretCodec .TryDecode(HostSecretCodec.Encode(Host(credentialId: credentialId)), out var document) .ShouldBeTrue(); document.ShouldNotBeNull(); document.SchemaVersion.ShouldBe(HostSecretCodec.CredentialIdSchemaVersion); document.Host.CredentialId.ShouldBe(credentialId); } [Fact] public void AKeyBoundHost_IsNotDraggedOntoTheCredentialVersion() { // The point of the rule. Adding credentials must not make every key-bound host in every vault // read-only on a client that understands keys perfectly well. HostSecretCodec .TryDecode(HostSecretCodec.Encode(Host(sshKeyId: DeployKey)), out var document) .ShouldBeTrue(); var version = document.ShouldNotBeNull().SchemaVersion; version.ShouldBe(HostSecretCodec.SshKeyIdSchemaVersion); version.ShouldBeLessThan(HostSecretCodec.CredentialIdSchemaVersion); } [Fact] public void AGroupedHost_IsWrittenAtTheVersionThatIntroducedGroups() { HostSecretCodec .TryDecode(HostSecretCodec.Encode(Host(groupId: Production)), out var document) .ShouldBeTrue(); document.ShouldNotBeNull(); document.SchemaVersion.ShouldBe(HostSecretCodec.GroupIdSchemaVersion); document.Host.GroupId.ShouldBe(Production); } /// /// /// The case that turned the version rule from a ladder into a maximum, and the one that would have shipped /// a real defect. A group is orthogonal to the two authentication bindings — a host may carry a credential /// and a group at once — so a switch returning the first match would have answered 3 for this host: /// a version that has no concept of the group the same write just stored. /// /// /// What that produces is worse than a wrong number. An older client reads schema 3, concludes the payload /// holds nothing it does not understand, offers to edit the host, and drops the group on save — silently, /// on every machine that has not been upgraded. Asserted for both bindings, because the ladder's order /// meant only one of the two would have been caught by a single case. /// /// [Theory] [InlineData(true)] [InlineData(false)] public void AHostThatIsBothBoundAndGrouped_IsWrittenAtTheHigherOfTheTwo(bool byCredential) { var host = byCredential ? Host(credentialId: Guid.CreateVersion7(), groupId: Production) : Host(sshKeyId: DeployKey, groupId: Production); HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue(); document.ShouldNotBeNull(); document.SchemaVersion.ShouldBe(HostSecretCodec.GroupIdSchemaVersion); document.Host.ShouldBe(host); } [Fact] public void AHostThatInheritsItsPort_IsWrittenAtTheVersionThatIntroducedInheritance() { HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(port: null)), out var document) .ShouldBeTrue(); document.ShouldNotBeNull(); document.SchemaVersion.ShouldBe( HostSecretCodec.PortInheritSchemaVersion, "a host stamped at 4 with its port omitted is not read-only on an older build — it is " + "undecodable there, because int Port reads 0 and TryValidate refuses it"); document.Host.Port.ShouldBeNull(); } [Fact] public void AHostPinnedToATypedPassword_IsWrittenAtTheSameVersionAsAnInheritedPort() { // Both halves of inheritance share a version because they arrive together and answer the same // question: what a host says when it declines to take its group's answer. HostSecretCodec .TryDecode(HostSecretCodec.Encode(Host(asksForPassword: true)), out var document) .ShouldBeTrue(); document.ShouldNotBeNull(); document.SchemaVersion.ShouldBe(HostSecretCodec.PortInheritSchemaVersion); document.Host.AsksForPassword.ShouldBe(true); } [Fact] public void AHostWearingTags_IsWrittenAtTheVersionThatIntroducedThem() { HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(tags: [Pci])), out var document) .ShouldBeTrue(); document.ShouldNotBeNull(); document.SchemaVersion.ShouldBe(HostSecretCodec.TagIdsSchemaVersion); document.Host.TagIds.ShouldBe(TagSet.Create([Pci])); } [Fact] public void ATaggedHostThatPinsItsPort_IsNotDraggedOntoTheInheritanceVersionOrBelowIt() { // The maximum, from both directions. Tags are independent of inheritance, so a host that only wears // one must not be written at 5 — and a host that only inherits must not be written at 6, which would // make it undecodable on a build that could have read it. HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(tags: [Pci])), out var tagged) .ShouldBeTrue(); HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(port: null)), out var inheriting) .ShouldBeTrue(); tagged.ShouldNotBeNull().SchemaVersion .ShouldBeGreaterThan(HostSecretCodec.PortInheritSchemaVersion); inheriting.ShouldNotBeNull().SchemaVersion .ShouldBeLessThan(HostSecretCodec.TagIdsSchemaVersion); } [Fact] public void AHostWithAnEmptyTagSet_IsWrittenAtTheVersionItWouldHaveHadWithout() { // Wearing no tags is not using the feature. If an empty set bumped the version, adding tags would // have made every host in every vault read-only on every machine that had not upgraded. HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(tags: [])), out var document) .ShouldBeTrue(); document.ShouldNotBeNull(); document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion); } [Fact] public void AddingInheritanceAndTags_DidNotChangeTheBytesOfAHostUsingNeither() { // The companion to the pin below, and the one that matters most for these two fields: a set that // serialised as [] and a flag that serialised as false would both land in every host in every vault, // and the first sync after the upgrade would push all of them as changed. var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, tags: [])); Encoding.UTF8.GetString(bytes).ShouldBe( """ {"schemaVersion":1,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false} """); } [Fact] public void AHostThatInheritsItsPort_OmitsTheKeyRatherThanWritingANull() { // The companion pin the old one could not be edited into. What an inheriting host must produce is // the absence of "port", not "port":null and not "port":22 — the first would decode as 0 on any // build, and the second is what inheritance exists to stop writing. var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, port: null)); Encoding.UTF8.GetString(bytes).ShouldBe( """ {"schemaVersion":5,"label":"prod-db","hostname":"db.internal","jumpHostIds":[],"options":{},"relayEnabled":false} """); } [Fact] public void AHostWearingTags_WritesThemLastAndSorted() { // Last, so every field that existed before them keeps its bytes; sorted, so two users who tapped the // same two chips in opposite orders produce one value and nothing to push. var bytes = HostSecretCodec.Encode( Host(username: null, notes: null, tags: [EuWest, Pci])); Encoding.UTF8.GetString(bytes).ShouldBe( """ {"schemaVersion":6,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"tagIds":["0192f0c8-8888-7c3d-8e4f-5a6b7c8d9e08","0192f0c8-9999-7c3d-8e4f-5a6b7c8d9e09"]} """); } [Fact] public void APayloadSayingItAsksForNoPassword_DecodesAsHavingSaidNothing() { // False and null mean the same thing — take the group's binding — so only one may reach the record. // Two spellings of one state is a difference the merge would report as a change nobody made. var payload = Encoding.UTF8.GetBytes( """ {"schemaVersion":5,"label":"prod-db","hostname":"db.internal","port":22,"asksForPassword":false} """); HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); document.ShouldNotBeNull().Host.AsksForPassword.ShouldBeNull(); } [Fact] public void APayloadRepeatingATag_DecodesAsTheSetItMeant() { // Written by some other client, and it must not compare unequal to the same set written once — or // the engine would push this host as changed on every pass for ever. var payload = Encoding.UTF8.GetBytes( $$""" {"schemaVersion":6,"label":"prod-db","hostname":"db.internal","port":22,"tagIds":["{{Pci}}","{{Pci}}"]} """); HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); document.ShouldNotBeNull().Host.TagIds.ShouldBe(TagSet.Create([Pci])); } [Fact] public void AddingTheKeyField_DidNotChangeTheBytesOfAHostWithoutOne() { // Pinned against a literal rather than against the codec, because the claim is about history: every // host already in every vault must re-encode to what it encoded before SshKeyId existed, or the // first sync after an upgrade would push the entire vault as changed. Byte-for-byte, so a new field // that serialised ahead of these — or a null that serialised as null — would fail here. 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 AHostBoundToAKeyByANewerClient_IsReadableButNotWritableHere() { // What an older build sees. Simulated by a version past this one rather than by an older codec, // since the mechanism is the comparison and not the field: read the item, refuse to re-encode it. var payload = Encoding.UTF8.GetBytes( """ {"schemaVersion":99,"label":"prod-db","hostname":"db.internal","port":22,"certificateId":"something this build has never heard of"} """); HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); document.ShouldNotBeNull(); document.IsReadOnly.ShouldBeTrue(); } [Fact] public void AMinimalHost_RoundTrips() { var host = Host(username: null, notes: null); HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue(); document!.Host.ShouldBe(host); document.Host.Username.ShouldBeNull(); document.Host.Notes.ShouldBeNull(); } [Fact] public void Encoding_IsDeterministic() { var host = Host(options: [("Compression", "yes"), ("ServerAliveInterval", "30")]); HostSecretCodec.Encode(host).ShouldBe(HostSecretCodec.Encode(host)); } [Fact] public void DirectiveOrder_DoesNotAffectTheEncoding() { // Two clients that agree on the content must produce the same bytes regardless of the order // the user happened to type the directives in. var one = Host(options: [("Compression", "yes"), ("ServerAliveInterval", "30")]); var other = Host(options: [("ServerAliveInterval", "30"), ("Compression", "yes")]); HostSecretCodec.Encode(one).ShouldBe(HostSecretCodec.Encode(other)); } [Fact] public void APayloadFromANewerSchema_IsReadableButReadOnly() { // The forward-compatibility rule. An old client can show the host but must not re-encode it, // because it has no representation for the newer client's extra fields and would drop them. var payload = Json(""" { "schemaVersion": 99, "label": "prod-db", "hostname": "db.internal", "port": 22, "unknownFutureField": { "nested": true } } """); HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); document!.Host.Label.ShouldBe("prod-db"); document.Host.Hostname.ShouldBe("db.internal"); document.IsReadOnly.ShouldBeTrue(); } [Fact] public void AnUnknownFieldAtTheCurrentSchema_IsSkippedRatherThanFatal() { var payload = Json(""" { "schemaVersion": 1, "label": "prod-db", "hostname": "db.internal", "port": 22, "somethingElse": 5 } """); HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); document!.IsReadOnly.ShouldBeFalse(); } [Theory] [InlineData("")] [InlineData("not json at all")] [InlineData("{")] [InlineData("[]")] [InlineData("null")] public void MalformedBytes_ReturnFalseRatherThanThrow(string text) { HostSecretCodec.TryDecode(Json(text), out var document).ShouldBeFalse(); document.ShouldBeNull(); } [Theory] [InlineData("""{ "schemaVersion": 0, "label": "a", "hostname": "b", "port": 22 }""")] [InlineData("""{ "schemaVersion": -1, "label": "a", "hostname": "b", "port": 22 }""")] [InlineData("""{ "schemaVersion": 1, "label": "", "hostname": "b", "port": 22 }""")] [InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "", "port": 22 }""")] [InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "b", "port": 0 }""")] [InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "b", "port": 70000 }""")] public void AStructurallyInvalidPayload_IsRejected(string json) { HostSecretCodec.TryDecode(Json(json), out _).ShouldBeFalse(); } [Fact] public void DuplicateDirectiveNamesDifferingOnlyInCase_AreRejected() { // Fails closed. SSH treats keywords case-insensitively, so this payload has no single // meaning; guessing which one wins would make two clients disagree about the same bytes. var payload = Json(""" { "schemaVersion": 1, "label": "prod-db", "hostname": "db.internal", "port": 22, "options": { "Compression": "yes", "compression": "no" } } """); HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse(); } [Fact] public void AnEmptyJumpHostId_IsRejected() { var payload = Json($$""" { "schemaVersion": 1, "label": "prod-db", "hostname": "db.internal", "port": 22, "jumpHostIds": ["{{Guid.Empty}}"] } """); HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse(); } [Fact] public void Encode_RefusesAnInvalidHost() { // Throwing rather than returning false, because unlike decoding, this is a caller bug: the // host came from this process and should have been validated before it got here. Should.Throw(() => HostSecretCodec.Encode(Host(label: " "))); Should.Throw(() => HostSecretCodec.Encode(Host(port: 0))); } [Fact] public void TheEncoding_CarriesNoPlaintextOutsideTheEnvelope() { // A reminder of what this codec is for: every one of these values is inside the ciphertext. // There is no plaintext host label anywhere in the system. var host = Host(label: "prod-db", notes: "root password in 1Password"); var text = Encoding.UTF8.GetString(HostSecretCodec.Encode(host)); text.Contains("prod-db", StringComparison.Ordinal).ShouldBeTrue(); text.Contains("1Password", StringComparison.Ordinal).ShouldBeTrue(); } private static byte[] Json(string text) => Encoding.UTF8.GetBytes(text); }