using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using static DodoSSH.Client.Sync.Tests.SyncHarness;
namespace DodoSSH.Client.Sync.Tests;
///
/// SSH keys through the same two-machine harness as hosts.
///
///
///
/// Deliberately not a copy of with the nouns changed. The six collision
/// outcomes are decided by ItemReconciler<TSecret>, which is one implementation shared by both
/// item types, so re-asserting all of them per type would test the same code twice and grow with every type
/// added. What is tested here is what is genuinely different about a key: its cipher, its merge, the fact
/// that it hands the server nothing in plaintext, that the reconciler's messages call it a key, and that its
/// items cannot be confused with a host's.
///
///
/// The two collision cases that are repeated — resurrection and an abandoned delete — are here
/// because they are the two that touch key material: one re-seals it under a new id, the other decides
/// whether a private key survives a deletion.
///
///
public sealed class SshKeySyncTests : IAsyncLifetime
{
private SyncHarness harness = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
///
public async ValueTask InitializeAsync() => harness = await CreateAsync();
///
public ValueTask DisposeAsync()
{
harness.Dispose();
return ValueTask.CompletedTask;
}
// ---- The uncontested paths ----
[Fact]
public async Task AKeyCreatedOnOneMachine_ReachesTheOther()
{
var entityId = await harness.First.CreateKeyAsync(
Key("deploy", material: "LAPTOP-MATERIAL", passphrase: "hunter2", notes: "rotate in June"));
await harness.SettleAsync();
var seen = await harness.Second.FindKeyAsync(entityId);
seen.Secret.Label.ShouldBe("deploy");
seen.Secret.PrivateKeyPem.ShouldContain("LAPTOP-MATERIAL");
seen.Secret.Passphrase.ShouldBe("hunter2");
seen.Secret.Notes.ShouldBe("rotate in June");
seen.HasUnsyncedChanges.ShouldBeFalse();
}
[Fact]
public async Task ThePull_AsksForKeysAsWellAsHosts()
{
await harness.First.SyncAsync();
var asked = harness.Server.LastPullTypes.ShouldNotBeNull();
asked.ShouldContain(SyncEntityType.Host);
asked.ShouldContain(
SyncEntityType.SshKey,
"a type the engine can reconcile but never requests would work in every unit test and never "
+ "sync");
}
[Fact]
public async Task AHostAndAKeyQueuedTogether_BothGoInOnePush()
{
var hostId = await harness.First.CreateAsync(Host("prod-db"));
var keyId = await harness.First.CreateKeyAsync(Key("deploy"));
await harness.First.SyncAsync();
harness.Server.PushCount.ShouldBe(1, "one outbox, one batch, whatever the item types in it");
await harness.Second.SyncAsync();
(await harness.Second.FindAsync(hostId)).Secret.Label.ShouldBe("prod-db");
(await harness.Second.FindKeyAsync(keyId)).Secret.Label.ShouldBe("deploy");
}
[Fact]
public async Task AKeyList_DoesNotShowHosts()
{
await harness.First.CreateAsync(Host("prod-db"));
await harness.First.CreateKeyAsync(Key("deploy"));
await harness.SettleAsync();
(await harness.Second.ListKeysAsync()).Items.ShouldHaveSingleItem()
.Secret.Label.ShouldBe("deploy");
(await harness.Second.ListAsync()).Items.ShouldHaveSingleItem()
.Secret.Label.ShouldBe("prod-db");
}
// ---- What the server is told ----
[Fact]
public async Task AKeyHandsTheServerNothingInPlaintext()
{
// The public half is supplied, which is the case where a fingerprint could have been derived and
// sent. The server has a column for one and would accept it; this client does not fill it, because a
// fingerprint is a stable identifier for a key pair and nothing in the product reads the column.
var entityId = await harness.First.CreateKeyAsync(
Key("deploy", publicKey: "ssh-ed25519 AAAAC3Nz deploy@laptop"));
var queued = await harness.First.Outbox
.FindAsync(VaultId, SyncEntityType.SshKey, entityId, Token);
queued.ShouldNotBeNull();
queued.Fields.ShouldBeNull("a key sends no plaintext fields at all, not an empty set of them");
await harness.SettleAsync();
var row = harness.Server.Find(entityId, SyncEntityType.SshKey).ShouldNotBeNull();
row.Fields.PublicKeyFingerprint.ShouldBeNull();
row.Fields.RelayEnabled.ShouldBeFalse();
row.Fields.Hostname.ShouldBeNull();
}
[Fact]
public async Task AHostAndAKeyWithTheSameId_AreDifferentItems()
{
// Not reachable through the repositories, which mint UUIDv7s, so it is arranged on the server. The
// point is that two things defend the separation independently: the item table is keyed on the type
// as well as the id, and the payload's AAD binds a resource type — so neither payload can be opened
// as the other even if a lookup did confuse them.
var sharedId = Guid.CreateVersion7();
harness.First.Keyring.TryGet(VaultId, out var vaultKey, out var generation).ShouldBeTrue();
harness.Server.ExternalUpsert(
sharedId,
HostCipher.Seal(Host("prod-db"), vaultKey.Span, sharedId, generation, itemVersion: 1),
new SyncPlaintextFields(),
SyncEntityType.Host);
harness.Server.ExternalUpsert(
sharedId,
SshKeyCipher.Seal(Key("deploy"), vaultKey.Span, sharedId, generation, itemVersion: 1),
null,
SyncEntityType.SshKey);
await harness.Second.SyncAsync();
var hosts = await harness.Second.ListAsync();
var keys = await harness.Second.ListKeysAsync();
hosts.Items.ShouldHaveSingleItem().Secret.Label.ShouldBe("prod-db");
keys.Items.ShouldHaveSingleItem().Secret.Label.ShouldBe("deploy");
hosts.Unreadable.ShouldBe(0);
keys.Unreadable.ShouldBe(0);
}
// ---- Merging ----
[Fact]
public async Task TwoMachinesEditingDifferentFieldsOfAKey_BothSurvive()
{
var entityId = await harness.First.CreateKeyAsync(Key("deploy", material: "SHARED"));
await harness.SettleAsync();
await harness.First.UpdateKeyAsync(entityId, Key("deploy-laptop", material: "SHARED"));
await harness.Second.UpdateKeyAsync(
entityId, Key("deploy", material: "SHARED", notes: "from the desktop"));
await harness.SettleAsync();
var first = (await harness.First.FindKeyAsync(entityId)).Secret;
var second = (await harness.Second.FindKeyAsync(entityId)).Secret;
first.ShouldBe(second);
first.Label.ShouldBe("deploy-laptop");
first.Notes.ShouldBe("from the desktop");
first.PrivateKeyPem.ShouldContain("SHARED");
(await ConflictKindsAsync()).ShouldBeEmpty();
}
[Fact]
public async Task BothReplacedTheKeyMaterial_NeitherKeyIsWrittenToTheConflictLog()
{
// The reason SshKeySecretMerge redacts. A host conflict records the value that lost so the user can
// put it back; doing that with a private key would copy a secret into a log that is designed to be
// read and is deliberately kept after acknowledgement.
var entityId = await harness.First.CreateKeyAsync(Key("deploy", material: "ORIGINAL"));
await harness.SettleAsync();
await harness.First.UpdateKeyAsync(entityId, Key("deploy", material: "LAPTOP-SECRET"));
await harness.Second.UpdateKeyAsync(entityId, Key("deploy", material: "DESKTOP-SECRET"));
await harness.SettleAsync();
(await ConflictKindsAsync()).ShouldContain(kind => kind == ConflictKind.FieldOverridden);
var details = await ConflictDetailsAsync();
details.ShouldContain(
detail => detail.Contains("PrivateKeyPem", StringComparison.Ordinal),
"the user still has to be told which field clashed");
foreach (var detail in details)
{
detail.ShouldNotContain("LAPTOP-SECRET");
detail.ShouldNotContain("DESKTOP-SECRET");
}
}
[Fact]
public async Task BothChangedThePassphrase_ThePassphraseIsNotInTheLogEither()
{
var entityId = await harness.First.CreateKeyAsync(Key("deploy", passphrase: "original"));
await harness.SettleAsync();
await harness.First.UpdateKeyAsync(entityId, Key("deploy", passphrase: "laptop-passphrase"));
await harness.Second.UpdateKeyAsync(entityId, Key("deploy", passphrase: "desktop-passphrase"));
await harness.SettleAsync();
foreach (var detail in await ConflictDetailsAsync())
{
detail.ShouldNotContain("laptop-passphrase");
detail.ShouldNotContain("desktop-passphrase");
}
}
// ---- Deletes, where key material can be lost ----
[Fact]
public async Task AKeyDeletedElsewhereWhileEditedHere_KeepsTheMaterialUnderANewName()
{
var entityId = await harness.First.CreateKeyAsync(Key("deploy", material: "IRREPLACEABLE"));
await harness.SettleAsync();
await harness.First.DeleteKeyAsync(entityId);
await harness.Second.UpdateKeyAsync(
entityId, Key("deploy", material: "IRREPLACEABLE", notes: "still in use"));
await harness.SettleAsync();
var restored = (await harness.First.ListKeysAsync()).Items.ShouldHaveSingleItem();
restored.EntityId.ShouldNotBe(entityId);
restored.Secret.Label.ShouldBe("deploy (restored)");
restored.Secret.Notes.ShouldBe("still in use");
restored.Secret.PrivateKeyPem.ShouldContain(
"IRREPLACEABLE", Case.Sensitive, "a resurrection that lost the key would rescue nothing");
(await ConflictKindsAsync()).ShouldContain(kind => kind == ConflictKind.RemoteDeleteResurrected);
}
[Fact]
public async Task AKeyEditedElsewhereAfterBeingDeletedHere_SurvivesAndIsCalledAKey()
{
var entityId = await harness.First.CreateKeyAsync(Key("deploy"));
await harness.SettleAsync();
await harness.First.UpdateKeyAsync(entityId, Key("deploy", notes: "still in use"));
await harness.Second.DeleteKeyAsync(entityId);
await harness.SettleAsync();
(await harness.First.FindKeyAsync(entityId)).Secret.Notes.ShouldBe("still in use");
(await ConflictKindsAsync()).ShouldContain(kind => kind == ConflictKind.LocalDeleteOverridden);
// The noun matters: someone told a host was edited elsewhere goes looking in the host list.
var details = await ConflictDetailsAsync();
details.ShouldContain(detail => detail.Contains("This SSH key was edited", StringComparison.Ordinal));
details.ShouldNotContain(detail => detail.Contains("This host was edited", StringComparison.Ordinal));
}
// ---- Helpers ----
private async Task> ConflictKindsAsync()
{
var first = await harness.First.ConflictsAsync();
var second = await harness.Second.ConflictsAsync();
return [.. first.Concat(second).Select(conflict => conflict.Kind)];
}
///
/// The raw stored detail, decoded as text rather than parsed. The redaction claims are claims about what
/// is absent from the bytes, and reading through the JSON model would only prove the material
/// is absent from the fields the model happens to name.
///
private async Task> ConflictDetailsAsync()
{
var first = await harness.First.ConflictsAsync();
var second = await harness.Second.ConflictsAsync();
return
[
.. first.Concat(second)
.Select(conflict => System.Text.Encoding.UTF8.GetString(conflict.Detail)),
];
}
}