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 { [Fact] public void AFullHost_RoundTrips() { var host = Host( label: "prod-db", hostname: "db.internal", port: 2222, username: "deploy", notes: "primary replica", jumps: [Bastion, Relay], options: [("ServerAliveInterval", "30"), ("Compression", "yes")]); HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue(); document.ShouldNotBeNull(); document.Host.ShouldBe(host); document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion); document.IsReadOnly.ShouldBeFalse(); } [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); }