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);
}
}