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. This one carries the credential, because that is the newer
/// of the two, plus a group — which is orthogonal to both and is what makes 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);
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 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);
}