namespace DodoSSH.Client.Domain.Tests; /// /// The credential record, its codec and its merge. /// /// /// Deliberately shorter than . The two types have the same shape and the same /// merge strategy, so what is covered here is what is specific to a credential: that an empty password is /// refused while a password of spaces is not, that "no username" has one spelling, and that the password /// never appears in a conflict log. /// public sealed class CredentialSecretTests { [Fact] public void AnEmptyUsername_IsTheSameAsNone() { // One spelling of "use the host's username", for the same reasons SshKeySecret.Passphrase normalises: // identical credentials encode identically, and "does this override the host?" has one answer. Credential(username: string.Empty).Username.ShouldBeNull(); Credential(username: null).Username.ShouldBeNull(); Credential(username: "postgres").Username.ShouldBe("postgres"); Credential(username: string.Empty).ShouldBe(Credential(username: null)); } [Fact] public void APasswordOfSpaces_IsAPassword() { // IsNullOrWhiteSpace would refuse this, and refusing it would lock someone out of a host over a // validation opinion. Only genuinely empty is refused. Credential(password: " ").TryValidate(out var reason).ShouldBeTrue(reason); Credential(password: string.Empty).TryValidate(out _).ShouldBeFalse(); } [Theory] [InlineData("", "hunter2", "needs a name")] [InlineData(" ", "hunter2", "needs a name")] [InlineData("db", "", "needs a password")] public void AnInvalidCredential_SaysWhatIsWrongWithIt(string label, string password, string expected) { var credential = new CredentialSecret { Label = label, Password = password }; credential.TryValidate(out var reason).ShouldBeFalse(); reason.ShouldNotBeNull().ShouldContain(expected); } [Fact] public void ACredential_SurvivesARoundTrip() { var credential = Credential(username: "postgres") with { Notes = "rotate in June" }; var encoded = CredentialSecretCodec.Encode(credential); CredentialSecretCodec.TryDecode(encoded, out var document).ShouldBeTrue(); document.ShouldNotBeNull(); document.Credential.ShouldBe(credential); document.SchemaVersion.ShouldBe(CredentialSecretCodec.CurrentSchemaVersion); document.IsReadOnly.ShouldBeFalse(); } [Fact] public void EncodingIsDeterministic() { CredentialSecretCodec.Encode(Credential()) .ShouldBe(CredentialSecretCodec.Encode(Credential())); } [Fact] public void AnEmptyUsernameIsNotWrittenAtAll() { CredentialSecretCodec.Encode(Credential(username: string.Empty)) .ShouldBe(CredentialSecretCodec.Encode(Credential(username: null))); } [Theory] [InlineData("not json")] [InlineData("{}")] [InlineData("""{"schemaVersion":1,"label":"db"}""")] [InlineData("""{"schemaVersion":1,"password":"hunter2"}""")] [InlineData("""{"schemaVersion":0,"label":"db","password":"hunter2"}""")] public void APayloadThatIsNotACredential_DoesNotDecode(string json) { CredentialSecretCodec .TryDecode(System.Text.Encoding.UTF8.GetBytes(json), out var document) .ShouldBeFalse(); document.ShouldBeNull(); } [Fact] public void ACredentialFromANewerClient_IsReadableButNotWritable() { var payload = System.Text.Encoding.UTF8.GetBytes( """ {"schemaVersion":99,"label":"db","password":"hunter2","totpSeed":"something new"} """); CredentialSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); document.ShouldNotBeNull().IsReadOnly.ShouldBeTrue(); } [Fact] public void EachSideEditingADifferentField_KeepsBoth() { var ancestor = Credential(); var local = ancestor with { Label = "db-primary" }; var remote = ancestor with { Notes = "from the desktop" }; var merged = CredentialSecretMerge.Merge(ancestor, local, remote); merged.HasConflicts.ShouldBeFalse(); merged.Merged.Label.ShouldBe("db-primary"); merged.Merged.Notes.ShouldBe("from the desktop"); merged.Merged.Password.ShouldBe(ancestor.Password); } [Fact] public void BothSidesChangingThePassword_NeverPutsEitherInTheConflictLog() { // The reason CredentialSecretMerge redacts, and the case for it is if anything plainer than for a // key: a discarded password is very often still the live password on some other system. var ancestor = Credential(); var local = ancestor with { Password = "LAPTOP-SECRET" }; var remote = ancestor with { Password = "DESKTOP-SECRET" }; var merged = CredentialSecretMerge.Merge(ancestor, local, remote); var conflict = merged.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe(nameof(CredentialSecret.Password)); var kept = conflict.Kept.ShouldNotBeNull(); var discarded = conflict.Discarded.ShouldNotBeNull(); foreach (var reported in new[] { kept, discarded }) { reported.ShouldNotContain("LAPTOP-SECRET"); reported.ShouldNotContain("DESKTOP-SECRET"); } // Redacting the report must not redact the value. merged.Merged.Password.ShouldBeOneOf("LAPTOP-SECRET", "DESKTOP-SECRET"); } [Fact] public void AUsernameClash_IsShownInFull() { // Not a secret, and knowing which account the merge dropped is the whole use of the notice. var ancestor = Credential(); var local = ancestor with { Username = "postgres" }; var remote = ancestor with { Username = "deploy" }; var merged = CredentialSecretMerge.Merge(ancestor, local, remote); var conflict = merged.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe(nameof(CredentialSecret.Username)); new[] { conflict.Kept, conflict.Discarded }.ShouldBe(["deploy", "postgres"], ignoreOrder: true); } [Fact] public void AUsernameClashingWithItsRemoval_SaysWhichSideHadNone() { var ancestor = Credential(username: "root"); var local = ancestor with { Username = null }; var remote = ancestor with { Username = "deploy" }; var merged = CredentialSecretMerge.Merge(ancestor, local, remote); var conflict = merged.Conflicts.ShouldHaveSingleItem(); conflict.Kept.ShouldBe("deploy"); conflict.Discarded.ShouldBe("(none)"); } private static CredentialSecret Credential( string password = "hunter2", string? username = null) => new() { Label = "db", Password = password, Username = username }; }