Files

562 lines
23 KiB
C#

using System.Text;
using static DodoSSH.Client.Domain.Tests.HostFactory;
namespace DodoSSH.Client.Domain.Tests;
/// <summary>
/// The payload encoding.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class HostSecretCodecTests
{
/// <remarks>
/// "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 <c>AsksForPassword</c>. This
/// one carries the credential, because that is the newer of the two, plus a group, a pair of tags and a
/// pair of pinned paths — which are orthogonal to the binding and are what make this host reach the
/// highest schema version a valid host can.
/// </remarks>
[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],
pinnedPaths: ["/var/www/app", "/var/log"]);
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);
}
/// <remarks>
/// <para>
/// 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 <c>switch</c> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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 AHostPinningPaths_IsWrittenAtTheVersionThatIntroducedThem()
{
HostSecretCodec
.TryDecode(HostSecretCodec.Encode(Host(pinnedPaths: ["/var/www/app"])), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.SchemaVersion.ShouldBe(HostSecretCodec.PinnedPathsSchemaVersion);
document.Host.PinnedPaths.ShouldBe(PinnedPathList.Create(["/var/www/app"]));
}
[Fact]
public void AHostWithNoPinnedPaths_IsWrittenAtTheVersionItWouldHaveHadWithout()
{
// Pinning nothing is not using the feature, for the same reason an empty tag set is not: bumping
// the version for it would have made every host in every vault read-only on every machine that had
// not upgraded yet.
HostSecretCodec.TryDecode(HostSecretCodec.Encode(Host(pinnedPaths: [])), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
}
[Fact]
public void ATaggedHostThatAlsoPinsPaths_IsWrittenAtTheHigherOfTheTwoVersions()
{
// Independent fields, so the version is a maximum over both rather than whichever this switch
// happened to check last — the same defect the group-and-binding case guards against above.
HostSecretCodec
.TryDecode(HostSecretCodec.Encode(Host(tags: [Pci], pinnedPaths: ["/var/www/app"])), out var document)
.ShouldBeTrue();
document.ShouldNotBeNull();
document.SchemaVersion.ShouldBe(HostSecretCodec.PinnedPathsSchemaVersion);
}
[Fact]
public void AHostPinningPaths_WritesThemLastAndInListOrder()
{
// Last, so every field that existed before them keeps its bytes; in list order rather than sorted,
// unlike tags — order is the meaning of a pinned-path list, so canonicalising it away would lose
// exactly what the feature is for.
var bytes = HostSecretCodec.Encode(
Host(username: null, notes: null, pinnedPaths: ["/var/www/app", "/var/log"]));
Encoding.UTF8.GetString(bytes).ShouldBe(
"""
{"schemaVersion":7,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"pinnedPaths":["/var/www/app","/var/log"]}
""");
}
[Fact]
public void APayloadWithoutPinnedPaths_DecodesAsPinningNothing()
{
// The compatibility case this field exists to pass: a payload written before pinned paths existed
// must read cleanly as a host that pins nothing, not fail to decode.
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":6,"label":"prod-db","hostname":"db.internal","port":22,"tagIds":["0192f0c8-8888-7c3d-8e4f-5a6b7c8d9e08"]}
""");
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull().Host.PinnedPaths.ShouldBe(PinnedPathList.Empty);
}
[Fact]
public void APayloadRepeatingAPinnedPath_DecodesKeepingTheFirstOccurrence()
{
// Written by some other client, and it must not compare unequal to the same list written once —
// or the engine would push this host as changed on every pass for ever.
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":7,"label":"prod-db","hostname":"db.internal","port":22,"pinnedPaths":["/var/www/app","/var/log","/var/www/app"]}
""");
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull().Host.PinnedPaths
.ShouldBe(PinnedPathList.Create(["/var/www/app", "/var/log"]));
}
[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<ArgumentException>(() => HostSecretCodec.Encode(Host(label: " ")));
Should.Throw<ArgumentException>(() => 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);
}