Public Access
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>
This commit is contained in:
@@ -1,42 +1,141 @@
|
||||
using System.Text;
|
||||
using static DodoSSH.Client.Domain.Tests.HostFactory;
|
||||
|
||||
namespace DodoSSH.Client.Domain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A group: one name, and the reasons it is only that.
|
||||
/// A group: a name, a parent, and the four things hosts under it fall back to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// There is very little behaviour here to test, which is itself the design — every field that was considered
|
||||
/// and left out (a parent, a member list) was left out because of what it would do to the merge. What these
|
||||
/// tests pin is that the envelope round-trips, that a nameless group cannot be stored, and that renaming the
|
||||
/// same group on two machines is reported rather than silently resolved.
|
||||
/// <para>
|
||||
/// A member list is still absent and still for the reason it always was — membership is a pointer on each
|
||||
/// host, so two people filing two machines into one group is two writes to two items. A parent is present,
|
||||
/// and it was not: see <see cref="HostGroupSecret"/> for why nesting stopped being worth refusing once the
|
||||
/// defaults made the chain something the connect path had to walk anyway.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What these pin is that the envelope round-trips, that a nameless or unstorable group cannot be stored,
|
||||
/// that a group carrying none of the new fields still encodes at version 1 byte for byte, and that every
|
||||
/// field is actually consulted by the merge rather than quietly deferring to the server forever.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HostGroupSecretTests
|
||||
{
|
||||
private static Guid Parent { get; } = Guid.Parse("0192f0c8-5555-7c3d-8e4f-5a6b7c8d9e05");
|
||||
|
||||
private static Guid TeamCredential { get; } = Guid.Parse("0192f0c8-6666-7c3d-8e4f-5a6b7c8d9e06");
|
||||
|
||||
[Fact]
|
||||
public void AGroup_RoundTrips()
|
||||
public void AFlatGroupWithNoDefaults_RoundTripsAtTheVersionItAlwaysHad()
|
||||
{
|
||||
var group = new HostGroupSecret { Label = "production" };
|
||||
var group = Group();
|
||||
|
||||
HostGroupSecretCodec.TryDecode(HostGroupSecretCodec.Encode(group), out var document)
|
||||
.ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull();
|
||||
document.Group.ShouldBe(group);
|
||||
document.SchemaVersion.ShouldBe(HostGroupSecretCodec.CurrentSchemaVersion);
|
||||
document.SchemaVersion.ShouldBe(HostGroupSecretCodec.BaseSchemaVersion);
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANestedGroupCarryingEveryDefault_RoundTrips()
|
||||
{
|
||||
var group = Group(
|
||||
parentId: Parent,
|
||||
defaultPort: 2222,
|
||||
defaultUsername: "deploy",
|
||||
defaultCredentialId: TeamCredential);
|
||||
|
||||
HostGroupSecretCodec.TryDecode(HostGroupSecretCodec.Encode(group), out var document)
|
||||
.ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull();
|
||||
document.Group.ShouldBe(group);
|
||||
document.SchemaVersion.ShouldBe(HostGroupSecretCodec.ParentAndDefaultsSchemaVersion);
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddingTheParentAndDefaults_DidNotChangeTheBytesOfAGroupWithoutThem()
|
||||
{
|
||||
// Pinned against a literal rather than against the codec, because the claim is about history: every
|
||||
// group already in every vault must re-encode to what it encoded before any of these fields existed,
|
||||
// or the first sync after an upgrade would push every group as changed. Byte-for-byte, so a new
|
||||
// field that serialised ahead of the name — or a null that serialised as null — would fail here.
|
||||
//
|
||||
// The version in this literal is the other half of the claim. This codec used to stamp
|
||||
// CurrentSchemaVersion unconditionally, and had that survived, a flat group would now be written at
|
||||
// 2 and read as uneditable on every machine that had not upgraded.
|
||||
var bytes = HostGroupSecretCodec.Encode(Group());
|
||||
|
||||
Encoding.UTF8.GetString(bytes).ShouldBe("""{"schemaVersion":1,"label":"production"}""");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GroupsCarryingOneNewField))]
|
||||
public void AGroupCarryingAnyNewField_IsWrittenAtTheVersionThatIntroducedThem(HostGroupSecret group)
|
||||
{
|
||||
HostGroupSecretCodec.TryDecode(HostGroupSecretCodec.Encode(group), out var document)
|
||||
.ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull();
|
||||
document.SchemaVersion.ShouldBe(
|
||||
HostGroupSecretCodec.ParentAndDefaultsSchemaVersion,
|
||||
"a version that cannot represent the field just written makes an older client decode the "
|
||||
+ "group as editable and drop that field on the next save");
|
||||
}
|
||||
|
||||
public static TheoryData<HostGroupSecret> GroupsCarryingOneNewField() =>
|
||||
[
|
||||
Group(parentId: Parent),
|
||||
Group(defaultPort: 2222),
|
||||
Group(defaultUsername: "deploy"),
|
||||
Group(defaultSshKeyId: DeployKey),
|
||||
Group(defaultCredentialId: TeamCredential),
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void AGroupWithNoName_IsRefused(string label)
|
||||
{
|
||||
new HostGroupSecret { Label = label }.TryValidate(out var reason).ShouldBeFalse();
|
||||
Group(label: label).TryValidate(out var reason).ShouldBeFalse();
|
||||
|
||||
reason.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_RejectsWhatCannotBeStored()
|
||||
{
|
||||
Group(parentId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
|
||||
Group(defaultPort: 0).TryValidate(out _).ShouldBeFalse();
|
||||
Group(defaultPort: 65536).TryValidate(out _).ShouldBeFalse();
|
||||
Group(defaultSshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
|
||||
Group(defaultCredentialId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
|
||||
|
||||
// Null is the absence of each of these, and the absence is always storable — it is what every group
|
||||
// in every vault written before this build carries.
|
||||
Group().TryValidate(out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AGroupDefaultsOneWay_NotTwo()
|
||||
{
|
||||
// The same exclusion a host is held to, for the same reason: a group naming both leaves "what do
|
||||
// hosts under this authenticate with?" without a single answer. Necessary but not sufficient — a
|
||||
// host naming a credential under a group naming a key is two valid records, so the resolver enforces
|
||||
// it again across the chain.
|
||||
var both = Group(defaultSshKeyId: DeployKey, defaultCredentialId: TeamCredential);
|
||||
|
||||
both.TryValidate(out var reason).ShouldBeFalse();
|
||||
reason.ShouldNotBeNull().ShouldContain("not both");
|
||||
|
||||
Group(defaultSshKeyId: DeployKey).TryValidate(out _).ShouldBeTrue();
|
||||
Group(defaultCredentialId: TeamCredential).TryValidate(out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AGroupWrittenByANewerClient_IsReadableButNotWritableHere()
|
||||
{
|
||||
@@ -64,10 +163,59 @@ public sealed class HostGroupSecretTests
|
||||
document.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUnstorablePayload_FailsToDecodeRatherThanProducingAGroupThatCannotBeSaved()
|
||||
{
|
||||
// A default port outside the range cannot have been written by this build, so it is either a bug in
|
||||
// some client or a corrupted write. Decoding it would produce a group the editor could open and
|
||||
// never save, with nothing on screen to say which field was the problem.
|
||||
var payload = Encoding.UTF8.GetBytes("""{"schemaVersion":2,"label":"production","defaultPort":0}""");
|
||||
|
||||
HostGroupSecretCodec.TryDecode(payload, out var document).ShouldBeFalse();
|
||||
|
||||
document.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryScalarField_IsRoutedThroughAMerge()
|
||||
{
|
||||
// A field added to HostGroupSecret but forgotten in the merge would silently revert to the remote
|
||||
// value forever. Changing each one only locally proves each is actually consulted.
|
||||
var ancestor = Group();
|
||||
|
||||
var local = ancestor with
|
||||
{
|
||||
Label = "prod",
|
||||
ParentId = Parent,
|
||||
DefaultPort = 2222,
|
||||
DefaultUsername = "deploy",
|
||||
DefaultCredentialId = TeamCredential,
|
||||
};
|
||||
|
||||
var result = HostGroupSecretMerge.Merge(ancestor, local, ancestor);
|
||||
|
||||
result.Merged.ShouldBe(local);
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheKeyFieldTheExclusionKeepsOutOfTheOtherTest_IsAlsoRoutedThroughAMerge()
|
||||
{
|
||||
// DefaultSshKeyId cannot appear beside DefaultCredentialId in one valid group, so it gets its own
|
||||
// pass rather than being the one field the guard above silently skips.
|
||||
var ancestor = Group();
|
||||
var local = ancestor with { DefaultSshKeyId = DeployKey };
|
||||
|
||||
var result = HostGroupSecretMerge.Merge(ancestor, local, ancestor);
|
||||
|
||||
result.Merged.ShouldBe(local);
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoDifferentRenames_AreReportedWithBothNames()
|
||||
{
|
||||
var ancestor = new HostGroupSecret { Label = "production" };
|
||||
var ancestor = Group();
|
||||
|
||||
var result = HostGroupSecretMerge.Merge(
|
||||
ancestor,
|
||||
@@ -83,6 +231,64 @@ public sealed class HostGroupSecretTests
|
||||
conflict.Discarded.ShouldBe("prod");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoDifferentReparentings_AreReportedWithBothParents()
|
||||
{
|
||||
// The clash that admits a cycle. This merge sees one group against one group, so it cannot know the
|
||||
// pair it is half of — it resolves, reports, and leaves the containment to the resolver's visited
|
||||
// set. What it must not do is resolve silently.
|
||||
var other = Guid.Parse("0192f0c8-7777-7c3d-8e4f-5a6b7c8d9e07");
|
||||
|
||||
var ancestor = Group();
|
||||
var result = HostGroupSecretMerge.Merge(
|
||||
ancestor,
|
||||
ancestor with { ParentId = Parent },
|
||||
ancestor with { ParentId = other });
|
||||
|
||||
result.Merged.ParentId.ShouldBe(other);
|
||||
|
||||
var conflict = result.Conflicts.ShouldHaveSingleItem();
|
||||
|
||||
conflict.Field.ShouldBe(nameof(HostGroupSecret.ParentId));
|
||||
conflict.Kept.ShouldBe(other.ToString());
|
||||
conflict.Discarded.ShouldBe(Parent.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADefaultClearedLocally_IsNotResurrectedByTheOtherSide()
|
||||
{
|
||||
// Null is a value here, not an absence: a group deliberately put back to no default user must not
|
||||
// silently regain one because the server's copy still names it.
|
||||
var ancestor = Group(defaultUsername: "deploy");
|
||||
var local = ancestor with { DefaultUsername = null };
|
||||
|
||||
var result = HostGroupSecretMerge.Merge(ancestor, local, ancestor);
|
||||
|
||||
result.Merged.DefaultUsername.ShouldBeNull();
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADefaultClearedOnOneSideAndChangedOnTheOther_NamesTheAbsenceInTheConflict()
|
||||
{
|
||||
// The formatter has to run for the null side too. Short-circuiting on null would print an empty
|
||||
// string where the conflict log needs to say that what lost was the removal of the default.
|
||||
var ancestor = Group(defaultPort: 22);
|
||||
|
||||
var result = HostGroupSecretMerge.Merge(
|
||||
ancestor,
|
||||
ancestor with { DefaultPort = null },
|
||||
ancestor with { DefaultPort = 2222 });
|
||||
|
||||
result.Merged.DefaultPort.ShouldBe(2222);
|
||||
|
||||
var conflict = result.Conflicts.ShouldHaveSingleItem();
|
||||
|
||||
conflict.Field.ShouldBe(nameof(HostGroupSecret.DefaultPort));
|
||||
conflict.Kept.ShouldBe("2222");
|
||||
conflict.Discarded.ShouldBe("no default port");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The case that would collide if membership were held on the group instead of on each host: two people
|
||||
/// filing two different machines into one group at the same time. It cannot reach the merge at all,
|
||||
@@ -91,7 +297,7 @@ public sealed class HostGroupSecretTests
|
||||
[Fact]
|
||||
public void FilingHostsIntoAGroup_DoesNotTouchTheGroup()
|
||||
{
|
||||
var ancestor = new HostGroupSecret { Label = "production" };
|
||||
var ancestor = Group();
|
||||
|
||||
var result = HostGroupSecretMerge.Merge(ancestor, ancestor, ancestor);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user