Public Access
562 lines
23 KiB
C#
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);
|
|
}
|