Files
DodoSSH/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs
T
jaap-janandClaude Opus 5 8c04ba60b0 Build the three things the phone's + needs, before the + exists
Steps 1 to 3 of docs/adding-hosts-on-the-phone.md: the domain half. Nothing
on either head has changed, which is deliberate — the plan orders these first
because everything the editors will bind to has to exist and be merge-safe
before a screen can offer it.

HostGroupSecret gains a parent and four defaults, and the codec gains the
version rule it never had. It stamped CurrentSchemaVersion unconditionally,
which was harmless with one field and one version and stops being harmless
here: upgrading one machine and renaming any group would have made that group
uneditable on every machine still on the old build. It now emits the lowest
version that loses nothing, so a flat group with no defaults still encodes at
version 1, byte for byte, pinned against a literal.

Tags become a real item over the reserved slot. Secret, codec, merge, cipher,
repository, both registries, the EF entity and a generated AddTagItem
migration. TagCipher names AadResourceType.Tag as a constant rather than
casting the wire type, because Tag is 5 on the wire and 8 in the crypto enum
and 5 there is Credential — a cast would seal every tag under the resource
type for a password, encrypt and decrypt perfectly on the machine that wrote
it, and only fail when another implementation refused the item, by which time
the AAD is frozen into stored ciphertext. HostTag stays reserved and unused:
the one thing the join buys over a set on the host is bought instead by
merging TagIds per id.

HostSecret grows TagIds and Port goes nullable, which is the change with the
widest blast radius and the only one that loses an item rather than locking
one. A host with no port of its own omits the property, an older build reads
int Port as 0, and TryValidate refuses it — unreadable rather than read-only.
That cost is confined to hosts which actually inherit, because the version is
a maximum over the fields present; the alternative, writing 22 into every
host, is the lie inheritance exists to stop telling.

One decision the plan did not specify. "Three states where there were two" is
four — key, credential, typed password, or the group's answer — and two
nullable ids carry three. Naming neither id now means inherit, so
AsksForPassword says "a typed password even under a group that lends a key"
out loud. Only true is ever written and a decoded false folds back to null, so
a host that never touched it encodes as it always did. Nothing already stored
changed meaning: no group could lend a binding before this build, so every
existing host resolves exactly as it did.

HostInheritance is the resolver, and its visited set is load-bearing rather
than defensive. Two clients can each re-parent A under B and B under A while
offline; the merge sees one item against one item and the server sees
ciphertext, so nothing upstream can refuse the pair. With inheritance the
chain is walked at connect time, so an unguarded cycle is not an undrawable
sidebar — it is a shell that never opens. Stopping at the first repeat
degrades it to a group that reads as a root, and clearing the parent is the
repair.

A tag set turns out to be the one field on a host that can never ask the user
anything. TagSet.ToIdMap keys by the value, so no key can hold two values, so
the both-sides-moved-differently branch of the keyed merge is unreachable —
asserted over the whole eight-row matrix. The conflict loop is kept anyway,
because that proof is one edit from ceasing to hold and what it would cause is
a discarded tag nothing records.

Three guard tests failed by design and were fixed rather than relaxed: the
ordered pull filter, the AAD pinning table, and the server's refusal of a
plaintext parent — that last one survives with its reason rewritten, because
the refusal now means "the parent is not the server's to hold" rather than
"there is no such thing as a parent". The prose that said groups are flat is
rewritten in all four places it appeared, not deleted.

The five view-model sites that read Port directly now go through the resolver,
which is a down payment on step 4 rather than the whole of it. HostFields.From
still emits the stored port, and that is the one remaining place where an
unresolved read would be a wrong wire rather than a wrong label.

Verified by the whole suite: 1382 tests over nineteen projects, none failing.
Both heads build. Nothing seen on a display, because nothing on a display has
changed yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:21:02 +02:00

477 lines
19 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 and a pair of tags —
/// 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]);
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 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);
}