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