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 any more: the two bindings are mutually exclusive, so a host may carry /// a key or a credential and never both. This one carries the credential because that is the newer of /// the two and therefore the highest schema version a valid host can reach; the key-bound case has its /// own version test below. /// [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); 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 ladder. 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 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); }