using System.Text; using static DodoSSH.Client.Domain.Tests.HostFactory; namespace DodoSSH.Client.Domain.Tests; /// /// A group: a name, a parent, and the four things hosts under it fall back to. /// /// /// /// 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 for why nesting stopped being worth refusing once the /// defaults made the chain something the connect path had to walk anyway. /// /// /// 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. /// /// 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 AFlatGroupWithNoDefaults_RoundTripsAtTheVersionItAlwaysHad() { var group = Group(); HostGroupSecretCodec.TryDecode(HostGroupSecretCodec.Encode(group), out var document) .ShouldBeTrue(); document.ShouldNotBeNull(); document.Group.ShouldBe(group); 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 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) { 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() { var payload = Encoding.UTF8.GetBytes( """ {"schemaVersion":99,"label":"production","colour":"a field this build has never heard of"} """); HostGroupSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); document.ShouldNotBeNull(); document.Group.Label.ShouldBe("production"); document.IsReadOnly.ShouldBeTrue(); } [Fact] public void AnUnnamedPayload_FailsToDecodeRatherThanProducingABlankGroup() { // Failing closed, as every codec in this folder does: a group with no name is indistinguishable in // the sidebar from the ungrouped heading it would sit beside. var payload = Encoding.UTF8.GetBytes("""{"schemaVersion":1}"""); HostGroupSecretCodec.TryDecode(payload, out var document).ShouldBeFalse(); 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 = Group(); var result = HostGroupSecretMerge.Merge( ancestor, ancestor with { Label = "prod" }, ancestor with { Label = "live" }); result.Merged.Label.ShouldBe("live"); var conflict = result.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe(nameof(HostGroupSecret.Label)); conflict.Kept.ShouldBe("live"); 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"); } /// /// 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, /// because neither of those actions writes to this item. /// [Fact] public void FilingHostsIntoAGroup_DoesNotTouchTheGroup() { var ancestor = Group(); var result = HostGroupSecretMerge.Merge(ancestor, ancestor, ancestor); result.HasConflicts.ShouldBeFalse(); result.Merged.ShouldBe(ancestor); } }