Public Access
Add the encrypted local cache and the sync client
Three new client projects, and the wire-contract fix they needed. DodoSSH.Client.Domain holds the decrypted item model and the three-way merge, with no I/O at all — so the suite that decides whether a credential can be lost runs in milliseconds with nothing to mock. Scalars defer to the server on a genuine clash so every replica resolves the same triple identically and two clients cannot ping-pong; directives merge per name so two people each adding one both keep theirs; the jump chain merges as a whole value because its order is the route. Whatever loses is returned rather than dropped. DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are already ciphertext, so an encrypted file would protect protected bytes at the cost of a native dependency. It keeps the server's state and the outbox in separate tables, which is what preserves the common ancestor a merge needs. One pending operation per item, enforced by a unique index. DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts — a change with no local work pending is plumbed as ciphertext — so a first sync of thousands of items does not run twice as many AEAD operations for nothing. Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The specification has required a per-item data key since crypto.md §3, the columns have existed since the first migration and DshAad.ItemPayload binds the id, but this record had nowhere to put either — so a spec-compliant item could not be transmitted at all. Found by writing the client that has to produce one. Also closes a hole in AadResourceType, which had no value for the HostTag and HostCredential that SyncEntityType has always listed. Four bugs the tests found, not review: - SQLite refuses to order or compare its own DateTimeOffset mapping, and throws at execution rather than model build. Collecting tombstones and listing conflicts are both that shape, so this was a crash waiting for the first user with a deleted host. Timestamps are integers now, by convention so a later field cannot be the one left unconverted. - SQLitePCLRaw 2.1.11, which EF resolves, is covered by GHSA-2m69-gcr7-jv3q. Pinned forward as a family. - Resurrecting content from a remote deletion cleared the original before queueing the copy. Two transactions, so a crash between them lost the work; reversed, and the rescued id is derived from the tombstone so a replay coalesces instead of duplicating. - Several equality assertions went through Shouldly's ShouldBe, which compares IEnumerable element-wise and so tested nothing about the Equals these types exist to provide. Corrected; the falsification that caught it went from 2 failures to 6. The push response's cursor is deliberately ignored. It sits after this client's own writes, so adopting it skips anything another client committed at a lower sequence in the window between a pull and a push — permanently. Re-reading one's own writes is idempotent and costs a page. The Contracts doc that invited the shortcut now says so. 593 tests, up from 448. The delete-versus-edit rules, the ancestor retention, the fresh operation id on coalesce and the cursor safeguard were each verified by breaking them and watching the right test fail.
This commit is contained in:
@@ -287,7 +287,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
|
||||
Guid.CreateVersion7(),
|
||||
SyncOperation.Upsert,
|
||||
null,
|
||||
new EncryptedPayload([1, 2, 3, 4], 1, 1),
|
||||
new EncryptedPayload([1, 2, 3, 4], [5, 6], Guid.CreateVersion7(), 1, 1),
|
||||
new SyncPlaintextFields()),
|
||||
]));
|
||||
|
||||
|
||||
@@ -455,7 +455,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
||||
Guid.CreateVersion7(),
|
||||
SyncOperation.Upsert,
|
||||
null,
|
||||
new EncryptedPayload([1, 2, 3], 1, 1),
|
||||
Payload([1, 2, 3]),
|
||||
new SyncPlaintextFields(RelayEnabled: false, Hostname: "secret.internal", Port: 22)),
|
||||
]));
|
||||
|
||||
@@ -479,7 +479,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
||||
Guid.CreateVersion7(),
|
||||
SyncOperation.Upsert,
|
||||
null,
|
||||
new EncryptedPayload([1, 2, 3], 1, 1),
|
||||
Payload([1, 2, 3]),
|
||||
new SyncPlaintextFields(RelayEnabled: true)),
|
||||
]));
|
||||
|
||||
@@ -505,7 +505,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
||||
entityId,
|
||||
SyncOperation.Upsert,
|
||||
null,
|
||||
new EncryptedPayload([1, 2, 3], 1, 1),
|
||||
Payload([1, 2, 3]),
|
||||
new SyncPlaintextFields(RelayEnabled: true, Hostname: "bastion.internal", Port: 22)),
|
||||
]));
|
||||
|
||||
@@ -595,7 +595,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
||||
Guid.CreateVersion7(),
|
||||
SyncOperation.Upsert,
|
||||
null,
|
||||
new EncryptedPayload([1], 1, 1),
|
||||
Payload([1]),
|
||||
null),
|
||||
]));
|
||||
|
||||
@@ -616,6 +616,13 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
||||
|
||||
private static string NewSubject() => $"user-{Guid.CreateVersion7():N}";
|
||||
|
||||
/// <summary>
|
||||
/// A structurally valid payload. The bytes are meaningless on purpose: the server cannot read
|
||||
/// any of them, and a test that pretended otherwise would be testing the wrong thing.
|
||||
/// </summary>
|
||||
private static EncryptedPayload Payload(byte[] envelope) =>
|
||||
new(envelope, WrappedDataKey: [0xD, 0xE], DataKeyId: Guid.CreateVersion7(), 1, 1);
|
||||
|
||||
private static SyncPushOperation NewOperation(Guid entityId, int? expectedVersion, byte[] envelope) =>
|
||||
new(
|
||||
Guid.CreateVersion7(),
|
||||
@@ -623,7 +630,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
||||
entityId,
|
||||
SyncOperation.Upsert,
|
||||
expectedVersion,
|
||||
new EncryptedPayload(envelope, 1, 1),
|
||||
Payload(envelope),
|
||||
new SyncPlaintextFields());
|
||||
|
||||
private static SyncPushRequest NewCreateBatch() =>
|
||||
|
||||
@@ -151,7 +151,7 @@ public sealed class DodoSshApiClientTests : IDisposable
|
||||
Guid.CreateVersion7(),
|
||||
SyncOperation.Upsert,
|
||||
null,
|
||||
new EncryptedPayload([1, 2, 3], 1, 1),
|
||||
new EncryptedPayload([1, 2, 3], [7, 7], Guid.CreateVersion7(), 1, 1),
|
||||
new SyncPlaintextFields()),
|
||||
]);
|
||||
|
||||
@@ -182,7 +182,7 @@ public sealed class DodoSshApiClientTests : IDisposable
|
||||
SyncOperation.Upsert,
|
||||
Version: 1,
|
||||
ChangeSequence: 5,
|
||||
Payload: new EncryptedPayload([4, 5, 6], 1, 1),
|
||||
Payload: new EncryptedPayload([4, 5, 6], [8, 8], Guid.CreateVersion7(), 1, 1),
|
||||
PlaintextFields: new SyncPlaintextFields(RelayEnabled: false),
|
||||
UpdatedAt: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000)),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The merge rules, in isolation. No ciphertext, no database, no HTTP — which is the point of
|
||||
keeping the item model in its own dependency-free project: the suite that decides whether a
|
||||
credential can be lost runs in milliseconds and has nothing to mock.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace DodoSSH.Client.Domain.Tests;
|
||||
|
||||
/// <summary>Builds hosts for the suites, so each test varies only what it is about.</summary>
|
||||
internal static class HostFactory
|
||||
{
|
||||
internal static Guid Bastion { get; } = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e01");
|
||||
|
||||
internal static Guid Relay { get; } = Guid.Parse("0192f0c8-2222-7c3d-8e4f-5a6b7c8d9e02");
|
||||
|
||||
internal static HostSecret Host(
|
||||
string label = "prod-db",
|
||||
string hostname = "db.internal",
|
||||
int port = 22,
|
||||
string? username = "deploy",
|
||||
string? notes = null,
|
||||
Guid[]? jumps = null,
|
||||
(string Name, string Value)[]? options = null,
|
||||
bool relayEnabled = false) =>
|
||||
new()
|
||||
{
|
||||
Label = label,
|
||||
Hostname = hostname,
|
||||
Port = port,
|
||||
Username = username,
|
||||
Notes = notes,
|
||||
JumpHostIds = jumps is null ? JumpChain.Empty : JumpChain.Create(jumps),
|
||||
Options = options is null
|
||||
? HostOptions.Empty
|
||||
: HostOptions.Create(options.Select(o => new HostOption(o.Name, o.Value))),
|
||||
RelayEnabled = relayEnabled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Text;
|
||||
using static DodoSSH.Client.Domain.Tests.HostFactory;
|
||||
|
||||
namespace DodoSSH.Client.Domain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The payload encoding.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two properties carry weight here. Determinism, because the sync engine compares to decide whether
|
||||
/// to push, and a codec that produced different bytes for the same host would make every pass look
|
||||
/// like a change. And failing closed on anything malformed, because these bytes are decrypted inside
|
||||
/// a sync pass where an exception would strand every item queued behind the bad one.
|
||||
/// </remarks>
|
||||
public sealed class HostSecretCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public void AFullHost_RoundTrips()
|
||||
{
|
||||
var host = Host(
|
||||
label: "prod-db",
|
||||
hostname: "db.internal",
|
||||
port: 2222,
|
||||
username: "deploy",
|
||||
notes: "primary replica",
|
||||
jumps: [Bastion, Relay],
|
||||
options: [("ServerAliveInterval", "30"), ("Compression", "yes")]);
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull();
|
||||
document.Host.ShouldBe(host);
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AMinimalHost_RoundTrips()
|
||||
{
|
||||
var host = Host(username: null, notes: null);
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
document!.Host.ShouldBe(host);
|
||||
document.Host.Username.ShouldBeNull();
|
||||
document.Host.Notes.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encoding_IsDeterministic()
|
||||
{
|
||||
var host = Host(options: [("Compression", "yes"), ("ServerAliveInterval", "30")]);
|
||||
|
||||
HostSecretCodec.Encode(host).ShouldBe(HostSecretCodec.Encode(host));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectiveOrder_DoesNotAffectTheEncoding()
|
||||
{
|
||||
// Two clients that agree on the content must produce the same bytes regardless of the order
|
||||
// the user happened to type the directives in.
|
||||
var one = Host(options: [("Compression", "yes"), ("ServerAliveInterval", "30")]);
|
||||
var other = Host(options: [("ServerAliveInterval", "30"), ("Compression", "yes")]);
|
||||
|
||||
HostSecretCodec.Encode(one).ShouldBe(HostSecretCodec.Encode(other));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APayloadFromANewerSchema_IsReadableButReadOnly()
|
||||
{
|
||||
// The forward-compatibility rule. An old client can show the host but must not re-encode it,
|
||||
// because it has no representation for the newer client's extra fields and would drop them.
|
||||
var payload = Json("""
|
||||
{
|
||||
"schemaVersion": 99,
|
||||
"label": "prod-db",
|
||||
"hostname": "db.internal",
|
||||
"port": 22,
|
||||
"unknownFutureField": { "nested": true }
|
||||
}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
document!.Host.Label.ShouldBe("prod-db");
|
||||
document.Host.Hostname.ShouldBe("db.internal");
|
||||
document.IsReadOnly.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUnknownFieldAtTheCurrentSchema_IsSkippedRatherThanFatal()
|
||||
{
|
||||
var payload = Json("""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "prod-db",
|
||||
"hostname": "db.internal",
|
||||
"port": 22,
|
||||
"somethingElse": 5
|
||||
}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
document!.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("not json at all")]
|
||||
[InlineData("{")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("null")]
|
||||
public void MalformedBytes_ReturnFalseRatherThanThrow(string text)
|
||||
{
|
||||
HostSecretCodec.TryDecode(Json(text), out var document).ShouldBeFalse();
|
||||
document.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{ "schemaVersion": 0, "label": "a", "hostname": "b", "port": 22 }""")]
|
||||
[InlineData("""{ "schemaVersion": -1, "label": "a", "hostname": "b", "port": 22 }""")]
|
||||
[InlineData("""{ "schemaVersion": 1, "label": "", "hostname": "b", "port": 22 }""")]
|
||||
[InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "", "port": 22 }""")]
|
||||
[InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "b", "port": 0 }""")]
|
||||
[InlineData("""{ "schemaVersion": 1, "label": "a", "hostname": "b", "port": 70000 }""")]
|
||||
public void AStructurallyInvalidPayload_IsRejected(string json)
|
||||
{
|
||||
HostSecretCodec.TryDecode(Json(json), out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateDirectiveNamesDifferingOnlyInCase_AreRejected()
|
||||
{
|
||||
// Fails closed. SSH treats keywords case-insensitively, so this payload has no single
|
||||
// meaning; guessing which one wins would make two clients disagree about the same bytes.
|
||||
var payload = Json("""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "prod-db",
|
||||
"hostname": "db.internal",
|
||||
"port": 22,
|
||||
"options": { "Compression": "yes", "compression": "no" }
|
||||
}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnEmptyJumpHostId_IsRejected()
|
||||
{
|
||||
var payload = Json($$"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "prod-db",
|
||||
"hostname": "db.internal",
|
||||
"port": 22,
|
||||
"jumpHostIds": ["{{Guid.Empty}}"]
|
||||
}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encode_RefusesAnInvalidHost()
|
||||
{
|
||||
// Throwing rather than returning false, because unlike decoding, this is a caller bug: the
|
||||
// host came from this process and should have been validated before it got here.
|
||||
Should.Throw<ArgumentException>(() => HostSecretCodec.Encode(Host(label: " ")));
|
||||
Should.Throw<ArgumentException>(() => HostSecretCodec.Encode(Host(port: 0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheEncoding_CarriesNoPlaintextOutsideTheEnvelope()
|
||||
{
|
||||
// A reminder of what this codec is for: every one of these values is inside the ciphertext.
|
||||
// There is no plaintext host label anywhere in the system.
|
||||
var host = Host(label: "prod-db", notes: "root password in 1Password");
|
||||
|
||||
var text = Encoding.UTF8.GetString(HostSecretCodec.Encode(host));
|
||||
|
||||
text.Contains("prod-db", StringComparison.Ordinal).ShouldBeTrue();
|
||||
text.Contains("1Password", StringComparison.Ordinal).ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static byte[] Json(string text) => Encoding.UTF8.GetBytes(text);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using static DodoSSH.Client.Domain.Tests.HostFactory;
|
||||
|
||||
namespace DodoSSH.Client.Domain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Merging a host field by field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The primitives are covered by <see cref="ThreeWayMergeTests"/>; this is about the wiring — that
|
||||
/// every field is actually routed through a merge, that the collections use the right strategy, and
|
||||
/// that a conflict names the field precisely enough for a user to act on it.
|
||||
/// </remarks>
|
||||
public sealed class HostSecretMergeTests
|
||||
{
|
||||
[Fact]
|
||||
public void NeitherSideChanged_ProducesTheSameHostAndNoConflicts()
|
||||
{
|
||||
var host = Host();
|
||||
|
||||
var result = HostSecretMerge.Merge(host, host, host);
|
||||
|
||||
result.Merged.ShouldBe(host);
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EachSideChangedADifferentField_BothSurvive()
|
||||
{
|
||||
// The reason a field-level merge is worth writing at all.
|
||||
var ancestor = Host();
|
||||
var local = ancestor with { Notes = "rotate quarterly" };
|
||||
var remote = ancestor with { Username = "postgres" };
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||
|
||||
result.Merged.Notes.ShouldBe("rotate quarterly");
|
||||
result.Merged.Username.ShouldBe("postgres");
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryScalarField_IsRoutedThroughAMerge()
|
||||
{
|
||||
// A field added to HostSecret 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 = Host();
|
||||
|
||||
var local = ancestor with
|
||||
{
|
||||
Label = "prod-db-1",
|
||||
Hostname = "db1.internal",
|
||||
Port = 2222,
|
||||
Username = "admin",
|
||||
Notes = "primary",
|
||||
JumpHostIds = JumpChain.Create([Bastion]),
|
||||
Options = HostOptions.Create([new HostOption("Compression", "yes")]),
|
||||
RelayEnabled = true,
|
||||
};
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, ancestor);
|
||||
|
||||
result.Merged.ShouldBe(local);
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AClashingScalar_TakesRemoteAndNamesTheFieldItDiscarded()
|
||||
{
|
||||
var ancestor = Host();
|
||||
var local = ancestor with { Hostname = "db-mine.internal" };
|
||||
var remote = ancestor with { Hostname = "db-theirs.internal" };
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||
|
||||
result.Merged.Hostname.ShouldBe("db-theirs.internal");
|
||||
|
||||
var conflict = result.Conflicts.ShouldHaveSingleItem();
|
||||
conflict.Field.ShouldBe(nameof(HostSecret.Hostname));
|
||||
conflict.Kept.ShouldBe("db-theirs.internal");
|
||||
conflict.Discarded.ShouldBe("db-mine.internal");
|
||||
conflict.DiscardedSide.ShouldBe(MergeSide.Local);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AClashingPort_IsReportedAsANumberNotAsBlank()
|
||||
{
|
||||
// Rendering the losing value is the entire point of the conflict record; a non-string field
|
||||
// that formatted to nothing would leave the user unable to restore it.
|
||||
var ancestor = Host(port: 22);
|
||||
var result = HostSecretMerge.Merge(ancestor, ancestor with { Port = 2222 }, ancestor with { Port = 2200 });
|
||||
|
||||
var conflict = result.Conflicts.ShouldHaveSingleItem();
|
||||
conflict.Field.ShouldBe(nameof(HostSecret.Port));
|
||||
conflict.Kept.ShouldBe("2200");
|
||||
conflict.Discarded.ShouldBe("2222");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AJumpChain_MergesAsAWholeRouteRatherThanAsASet()
|
||||
{
|
||||
// Deliberate, and the opposite of how the directives merge. Unioning two chains would
|
||||
// produce a route neither user configured and would silently change which machine is
|
||||
// reached through which — so this conflicts instead, and reports the discarded route.
|
||||
var ancestor = Host();
|
||||
var local = ancestor with { JumpHostIds = JumpChain.Create([Bastion]) };
|
||||
var remote = ancestor with { JumpHostIds = JumpChain.Create([Relay]) };
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||
|
||||
result.Merged.JumpHostIds.Equals(JumpChain.Create([Relay])).ShouldBeTrue();
|
||||
result.Merged.JumpHostIds.Count.ShouldBe(1);
|
||||
|
||||
var conflict = result.Conflicts.ShouldHaveSingleItem();
|
||||
conflict.Field.ShouldBe(nameof(HostSecret.JumpHostIds));
|
||||
conflict.Discarded.ShouldNotBeNull();
|
||||
conflict.Discarded.ShouldContain(Bastion.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AReorderedJumpChain_IsAChange()
|
||||
{
|
||||
var ancestor = Host(jumps: [Bastion, Relay]);
|
||||
var local = ancestor with { JumpHostIds = JumpChain.Create([Relay, Bastion]) };
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, ancestor);
|
||||
|
||||
result.Merged.JumpHostIds.Equals(JumpChain.Create([Relay, Bastion])).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Directives_MergePerNameSoBothAdditionsSurvive()
|
||||
{
|
||||
var ancestor = Host();
|
||||
var local = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "yes")]) };
|
||||
var remote = ancestor with
|
||||
{
|
||||
Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]),
|
||||
};
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||
|
||||
result.Merged.Options.Count.ShouldBe(2);
|
||||
result.Merged.Options.TryGetValue("Compression", out var compression).ShouldBeTrue();
|
||||
compression.ShouldBe("yes");
|
||||
result.Merged.Options.TryGetValue("ServerAliveInterval", out var keepAlive).ShouldBeTrue();
|
||||
keepAlive.ShouldBe("30");
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AClashingDirective_NamesTheDirectiveNotJustTheField()
|
||||
{
|
||||
// "Options changed" would be useless. The user needs to know which one.
|
||||
var ancestor = Host(options: [("Compression", "yes")]);
|
||||
var local = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "no")]) };
|
||||
var remote = ancestor with
|
||||
{
|
||||
Options = HostOptions.Create([new HostOption("Compression", "delayed")]),
|
||||
};
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||
|
||||
var conflict = result.Conflicts.ShouldHaveSingleItem();
|
||||
conflict.Field.ShouldBe("Options[Compression]");
|
||||
conflict.Kept.ShouldBe("delayed");
|
||||
conflict.Discarded.ShouldBe("no");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARemovedDirectiveTheOtherSideEdited_KeepsTheValue()
|
||||
{
|
||||
var ancestor = Host(options: [("Compression", "yes")]);
|
||||
var local = ancestor with { Options = HostOptions.Empty };
|
||||
var remote = ancestor with { Options = HostOptions.Create([new HostOption("Compression", "no")]) };
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||
|
||||
result.Merged.Options.TryGetValue("Compression", out var value).ShouldBeTrue();
|
||||
value.ShouldBe("no");
|
||||
result.Conflicts.ShouldHaveSingleItem().DiscardedWasRemoval.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheMergedHost_IsAlwaysValidWhenBothInputsWere()
|
||||
{
|
||||
// A merge that produced an unstorable host would strand the item: it could never be pushed
|
||||
// and the conflict could never clear.
|
||||
var ancestor = Host();
|
||||
var local = ancestor with { Label = "mine", Port = 2222 };
|
||||
var remote = ancestor with { Label = "theirs", Hostname = "other.internal" };
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||
|
||||
result.Merged.TryValidate(out var error).ShouldBeTrue(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvingAConflictConverges()
|
||||
{
|
||||
// Two clients, both merging, must reach the same host and then stop. Re-merging the result
|
||||
// against the remote produces no further conflict — which is what stops an endless
|
||||
// push-conflict-merge-push loop between two machines.
|
||||
var ancestor = Host();
|
||||
var local = ancestor with { Notes = "mine", Username = "a" };
|
||||
var remote = ancestor with { Notes = "theirs", Hostname = "other.internal" };
|
||||
|
||||
var first = HostSecretMerge.Merge(ancestor, local, remote);
|
||||
first.HasConflicts.ShouldBeTrue();
|
||||
|
||||
var second = HostSecretMerge.Merge(remote, first.Merged, remote);
|
||||
|
||||
second.HasConflicts.ShouldBeFalse();
|
||||
second.Merged.ShouldBe(first.Merged);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
namespace DodoSSH.Client.Domain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The merge primitives.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These are the rules the whole sync story rests on, so they are tested as rules rather than
|
||||
/// through the sync engine: every triple of (ancestor, local, remote) states is enumerated, and each
|
||||
/// asserts both the surviving value and — where a side lost — that the losing value came back.
|
||||
/// </remarks>
|
||||
public sealed class ThreeWayMergeTests
|
||||
{
|
||||
// ---- Scalar ----
|
||||
|
||||
[Fact]
|
||||
public void NeitherSideChanged_IsAgreement()
|
||||
{
|
||||
var merge = ThreeWayMerge.Scalar("base", "base", "base");
|
||||
|
||||
merge.Value.ShouldBe("base");
|
||||
merge.Decision.ShouldBe(MergeDecision.Agreed);
|
||||
merge.IsConflicted.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyLocalChanged_KeepsTheLocalValue()
|
||||
{
|
||||
var merge = ThreeWayMerge.Scalar("base", "mine", "base");
|
||||
|
||||
merge.Value.ShouldBe("mine");
|
||||
merge.Decision.ShouldBe(MergeDecision.TookLocal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyRemoteChanged_KeepsTheRemoteValue()
|
||||
{
|
||||
var merge = ThreeWayMerge.Scalar("base", "base", "theirs");
|
||||
|
||||
merge.Value.ShouldBe("theirs");
|
||||
merge.Decision.ShouldBe(MergeDecision.TookRemote);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothSidesMadeTheSameChange_IsAgreementRatherThanAConflict()
|
||||
{
|
||||
// Two people fixing the same typo must not be asked to arbitrate.
|
||||
var merge = ThreeWayMerge.Scalar("base", "fixed", "fixed");
|
||||
|
||||
merge.Value.ShouldBe("fixed");
|
||||
merge.Decision.ShouldBe(MergeDecision.Agreed);
|
||||
merge.IsConflicted.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothSidesChangedDifferently_TakesRemoteAndReportsLocal()
|
||||
{
|
||||
// Remote wins so that every replica resolves the same triple identically; without a fixed
|
||||
// winner two clients each keep their own value and push over each other forever.
|
||||
var merge = ThreeWayMerge.Scalar("base", "mine", "theirs");
|
||||
|
||||
merge.Value.ShouldBe("theirs");
|
||||
merge.Decision.ShouldBe(MergeDecision.Conflicted);
|
||||
merge.IsConflicted.ShouldBeTrue();
|
||||
|
||||
// The whole justification for picking a side: the other one is handed back, never dropped.
|
||||
merge.Discarded.ShouldBe("mine");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AConflictedMerge_IsIdempotentOnceResolved()
|
||||
{
|
||||
// Convergence, spelled out. Having taken the remote value, re-merging against the same
|
||||
// remote must be agreement rather than a fresh conflict — otherwise the two clients
|
||||
// ping-pong.
|
||||
var first = ThreeWayMerge.Scalar("base", "mine", "theirs");
|
||||
var second = ThreeWayMerge.Scalar("theirs", first.Value, "theirs");
|
||||
|
||||
second.Decision.ShouldBe(MergeDecision.Agreed);
|
||||
second.Value.ShouldBe("theirs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scalar_UsesTheSuppliedComparer()
|
||||
{
|
||||
// Ordinal by default would call these a conflict; the comparer is how a field opts out.
|
||||
var merge = ThreeWayMerge.Scalar("base", "SAME", "same", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
merge.Decision.ShouldBe(MergeDecision.Agreed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scalar_HandlesNullOnAnySide()
|
||||
{
|
||||
// Nullable fields are the common case — Username and Notes are both optional — so a null
|
||||
// must be an ordinary value here rather than a special case that throws.
|
||||
ThreeWayMerge.Scalar<string?>(null, "set", null).Value.ShouldBe("set");
|
||||
ThreeWayMerge.Scalar<string?>("was", null, "was").Value.ShouldBeNull();
|
||||
ThreeWayMerge.Scalar<string?>(null, null, null).Decision.ShouldBe(MergeDecision.Agreed);
|
||||
}
|
||||
|
||||
// ---- Map ----
|
||||
|
||||
[Fact]
|
||||
public void EachSideAddedADifferentKey_KeepsBoth()
|
||||
{
|
||||
// The single most visible benefit of a per-key merge over comparing whole collections: two
|
||||
// people adding different directives to one host both keep theirs.
|
||||
var merge = Map(
|
||||
ancestor: [],
|
||||
local: [("Compression", "yes")],
|
||||
remote: [("ServerAliveInterval", "30")]);
|
||||
|
||||
merge.Merged.Count.ShouldBe(2);
|
||||
merge.Merged["Compression"].ShouldBe("yes");
|
||||
merge.Merged["ServerAliveInterval"].ShouldBe("30");
|
||||
merge.Conflicts.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EachSideAddedTheSameKeyDifferently_TakesRemoteAndReportsLocal()
|
||||
{
|
||||
var merge = Map(
|
||||
ancestor: [],
|
||||
local: [("Port", "2222")],
|
||||
remote: [("Port", "2200")]);
|
||||
|
||||
merge.Merged["Port"].ShouldBe("2200");
|
||||
|
||||
var conflict = merge.Conflicts.ShouldHaveSingleItem();
|
||||
conflict.Key.ShouldBe("Port");
|
||||
conflict.Kept.ShouldBe("2200");
|
||||
conflict.Discarded.ShouldBe("2222");
|
||||
conflict.DiscardedSide.ShouldBe(MergeSide.Local);
|
||||
conflict.DiscardedWasRemoval.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneSideRemovedAKeyTheOtherLeftAlone_RemovesIt()
|
||||
{
|
||||
Map(
|
||||
ancestor: [("Compression", "yes")],
|
||||
local: [],
|
||||
remote: [("Compression", "yes")])
|
||||
.Merged.ShouldBeEmpty();
|
||||
|
||||
Map(
|
||||
ancestor: [("Compression", "yes")],
|
||||
local: [("Compression", "yes")],
|
||||
remote: [])
|
||||
.Merged.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoteEditedAKeyLocalRemoved_KeepsTheEditAndReportsTheRemoval()
|
||||
{
|
||||
// An edit outlives a removal in both directions. Re-applying a removal costs one click;
|
||||
// a discarded value may be the only copy of something the user cannot reconstruct.
|
||||
var merge = Map(
|
||||
ancestor: [("Compression", "yes")],
|
||||
local: [],
|
||||
remote: [("Compression", "no")]);
|
||||
|
||||
merge.Merged["Compression"].ShouldBe("no");
|
||||
|
||||
var conflict = merge.Conflicts.ShouldHaveSingleItem();
|
||||
conflict.DiscardedSide.ShouldBe(MergeSide.Local);
|
||||
conflict.DiscardedWasRemoval.ShouldBeTrue();
|
||||
conflict.Kept.ShouldBe("no");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalEditedAKeyRemoteRemoved_KeepsTheEditAndReportsTheRemoval()
|
||||
{
|
||||
var merge = Map(
|
||||
ancestor: [("Compression", "yes")],
|
||||
local: [("Compression", "no")],
|
||||
remote: []);
|
||||
|
||||
merge.Merged["Compression"].ShouldBe("no");
|
||||
|
||||
var conflict = merge.Conflicts.ShouldHaveSingleItem();
|
||||
|
||||
// The overridden side is the remote one here, which is what makes this asymmetric from the
|
||||
// scalar rule: the tie-break is "a value beats an absence" before it is "remote wins".
|
||||
conflict.DiscardedSide.ShouldBe(MergeSide.Remote);
|
||||
conflict.DiscardedWasRemoval.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BothSidesRemovedTheSameKey_IsAgreement()
|
||||
{
|
||||
var merge = Map(
|
||||
ancestor: [("Compression", "yes")],
|
||||
local: [],
|
||||
remote: []);
|
||||
|
||||
merge.Merged.ShouldBeEmpty();
|
||||
merge.Conflicts.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnchangedKeys_SurviveAlongsideConflictingOnes()
|
||||
{
|
||||
// A conflict on one key must not disturb its neighbours, which is the difference between
|
||||
// field-level merge and replacing the collection.
|
||||
var merge = Map(
|
||||
ancestor: [("Keep", "same"), ("Fight", "base")],
|
||||
local: [("Keep", "same"), ("Fight", "mine")],
|
||||
remote: [("Keep", "same"), ("Fight", "theirs")]);
|
||||
|
||||
merge.Merged["Keep"].ShouldBe("same");
|
||||
merge.Merged["Fight"].ShouldBe("theirs");
|
||||
merge.Conflicts.ShouldHaveSingleItem().Key.ShouldBe("Fight");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_TreatsKeysUnderTheSuppliedComparer()
|
||||
{
|
||||
// SSH keywords are case-insensitive. Treating these as two keys would let a host carry
|
||||
// both Compression and compression, which no client could then reconcile.
|
||||
var merge = Map(
|
||||
ancestor: [("Compression", "yes")],
|
||||
local: [("compression", "yes")],
|
||||
remote: [("COMPRESSION", "yes")]);
|
||||
|
||||
merge.Merged.Count.ShouldBe(1);
|
||||
merge.Conflicts.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_NeverDropsAValueWithoutReportingIt()
|
||||
{
|
||||
// The invariant, asserted directly rather than inferred from the cases above: every value
|
||||
// present on either side either survives into the merge or appears in the conflict list.
|
||||
var local = new[] { ("A", "1"), ("B", "2"), ("C", "3") };
|
||||
var remote = new[] { ("A", "9"), ("B", "2"), ("D", "4") };
|
||||
|
||||
var merge = Map(ancestor: [("A", "0"), ("B", "2")], local: local, remote: remote);
|
||||
|
||||
foreach (var (key, value) in local.Concat(remote))
|
||||
{
|
||||
var survived = merge.Merged.TryGetValue(key, out var kept)
|
||||
&& string.Equals(kept, value, StringComparison.Ordinal);
|
||||
|
||||
var reported = merge.Conflicts.Any(c =>
|
||||
HostOption.NameComparer.Equals(c.Key, key)
|
||||
&& string.Equals(c.Discarded, value, StringComparison.Ordinal));
|
||||
|
||||
(survived || reported).ShouldBeTrue($"{key}={value} was neither kept nor reported.");
|
||||
}
|
||||
}
|
||||
|
||||
private static MapMerge<string, string> Map(
|
||||
(string Key, string Value)[] ancestor,
|
||||
(string Key, string Value)[] local,
|
||||
(string Key, string Value)[] remote) =>
|
||||
ThreeWayMerge.Map(
|
||||
ToMap(ancestor),
|
||||
ToMap(local),
|
||||
ToMap(remote),
|
||||
HostOption.NameComparer,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
private static Dictionary<string, string> ToMap((string Key, string Value)[] entries)
|
||||
{
|
||||
var map = new Dictionary<string, string>(HostOption.NameComparer);
|
||||
|
||||
foreach (var (key, value) in entries)
|
||||
{
|
||||
map[key] = value;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using static DodoSSH.Client.Domain.Tests.HostFactory;
|
||||
|
||||
namespace DodoSSH.Client.Domain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Equality of the collection types, and of the host that holds them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This suite guards a failure that would be invisible rather than loud. If any of these compared by
|
||||
/// reference, the merge would report every host as changed on every sync pass, two identical edits
|
||||
/// would register as a conflict, and the engine would push spurious updates forever. Nothing would
|
||||
/// throw and no test elsewhere would obviously fail — which is exactly why these are asserted here.
|
||||
/// </remarks>
|
||||
public sealed class ValueSemanticsTests
|
||||
{
|
||||
[Fact]
|
||||
public void TwoHostsWithEqualContents_AreEqual()
|
||||
{
|
||||
var one = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]);
|
||||
var other = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]);
|
||||
|
||||
one.ShouldBe(other);
|
||||
one.GetHashCode().ShouldBe(other.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostDifferingOnlyInACollection_IsNotEqual()
|
||||
{
|
||||
Host(jumps: [Bastion]).ShouldNotBe(Host(jumps: [Relay]));
|
||||
Host(options: [("Compression", "yes")]).ShouldNotBe(Host(options: [("Compression", "no")]));
|
||||
}
|
||||
|
||||
// Note on the assertion style below: these call Equals and the operators directly rather than
|
||||
// going through ShouldBe. Both of these types implement IReadOnlyList, and Shouldly compares
|
||||
// enumerables element by element — so ShouldBe would pass whatever Equals did, which is the one
|
||||
// thing this suite exists to check.
|
||||
|
||||
[Fact]
|
||||
public void AJumpChain_ComparesByContentsAndOrder()
|
||||
{
|
||||
JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeTrue();
|
||||
(JumpChain.Create([Bastion, Relay]) == JumpChain.Create([Bastion, Relay])).ShouldBeTrue();
|
||||
|
||||
JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Relay, Bastion])).ShouldBeFalse();
|
||||
JumpChain.Create([Bastion]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeFalse();
|
||||
|
||||
JumpChain.Create([]).Equals(JumpChain.Empty).ShouldBeTrue();
|
||||
JumpChain.Create([Bastion]).Equals(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AJumpChain_HashesByContents()
|
||||
{
|
||||
JumpChain.Create([Bastion, Relay]).GetHashCode()
|
||||
.ShouldBe(JumpChain.Create([Bastion, Relay]).GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AJumpChain_ComparesEqualAcrossTheSpanAndSequenceFactories()
|
||||
{
|
||||
Guid[] hops = [Bastion, Relay];
|
||||
|
||||
JumpChain.Create(hops.AsSpan()).Equals(JumpChain.Create(hops.AsEnumerable())).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Directives_CompareIgnoringNameCaseAndInputOrder()
|
||||
{
|
||||
// Both halves matter. Case, because a merge picks whichever spelling it saw first and two
|
||||
// clients must still agree. Order, because the collection canonicalises and a user typing
|
||||
// the same two directives in the other order has not changed anything.
|
||||
var one = HostOptions.Create([new HostOption("Compression", "yes"), new HostOption("Port", "22")]);
|
||||
var other = HostOptions.Create([new HostOption("port", "22"), new HostOption("compression", "yes")]);
|
||||
|
||||
one.Equals(other).ShouldBeTrue();
|
||||
(one == other).ShouldBeTrue();
|
||||
one.GetHashCode().ShouldBe(other.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Directives_CompareValuesCaseSensitively()
|
||||
{
|
||||
// Keywords are case-insensitive in SSH; values are not. "yes" and "YES" happen to mean the
|
||||
// same to sshd, but this layer must not decide that for every directive that exists.
|
||||
HostOptions.Create([new HostOption("Compression", "yes")])
|
||||
.Equals(HostOptions.Create([new HostOption("Compression", "YES")]))
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Directives_CompareUnequalWhenOneSideHasMore()
|
||||
{
|
||||
var one = HostOptions.Create([new HostOption("Compression", "yes")]);
|
||||
var other = HostOptions.Create(
|
||||
[new HostOption("Compression", "yes"), new HostOption("Port", "22")]);
|
||||
|
||||
one.Equals(other).ShouldBeFalse();
|
||||
HostOptions.Empty.Equals(one).ShouldBeFalse();
|
||||
one.Equals(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Directives_AreHeldInNameOrder()
|
||||
{
|
||||
var options = HostOptions.Create(
|
||||
[
|
||||
new HostOption("ServerAliveInterval", "30"),
|
||||
new HostOption("Compression", "yes"),
|
||||
]);
|
||||
|
||||
options[0].Name.ShouldBe("Compression");
|
||||
options[1].Name.ShouldBe("ServerAliveInterval");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARepeatedDirectiveName_IsRefused()
|
||||
{
|
||||
// A repeated keyword has no merge key, so M1 cannot represent it. Refusing is the honest
|
||||
// answer; silently keeping one of the two would lose data without saying so.
|
||||
var duplicate = new[]
|
||||
{
|
||||
new HostOption("Compression", "yes"),
|
||||
new HostOption("compression", "no"),
|
||||
};
|
||||
|
||||
HostOptions.TryCreate(duplicate, out _, out var error).ShouldBeFalse();
|
||||
error.ShouldNotBeNull();
|
||||
error.Contains("more than once", StringComparison.Ordinal).ShouldBeTrue();
|
||||
|
||||
Should.Throw<ArgumentException>(() => HostOptions.Create(duplicate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ABlankDirectiveName_IsRefused()
|
||||
{
|
||||
HostOptions.TryCreate([new HostOption(" ", "x")], out _, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostBuiltWithWith_KeepsCollectionEquality()
|
||||
{
|
||||
// `with` copies the collection references, so this would pass even under reference equality.
|
||||
// It is here because the merge builds its result with an object initialiser rather than
|
||||
// `with`, and both paths have to agree.
|
||||
var host = Host(options: [("Compression", "yes")]);
|
||||
var copy = host with { Notes = "changed" };
|
||||
|
||||
copy.Options.Equals(host.Options).ShouldBeTrue();
|
||||
(copy with { Notes = host.Notes }).ShouldBe(host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryValidate_RejectsWhatCannotBeStored()
|
||||
{
|
||||
Host(label: "").TryValidate(out _).ShouldBeFalse();
|
||||
Host(hostname: " ").TryValidate(out _).ShouldBeFalse();
|
||||
Host(port: 65536).TryValidate(out _).ShouldBeFalse();
|
||||
Host(jumps: [Guid.Empty]).TryValidate(out _).ShouldBeFalse();
|
||||
Host().TryValidate(out _).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.134, )",
|
||||
"resolved": "3.0.134",
|
||||
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
|
||||
},
|
||||
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.0, )",
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==",
|
||||
"dependencies": {
|
||||
"Castle.Core": "5.1.1"
|
||||
}
|
||||
},
|
||||
"Shouldly": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.3.0, )",
|
||||
"resolved": "4.3.0",
|
||||
"contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==",
|
||||
"dependencies": {
|
||||
"DiffEngine": "11.3.0",
|
||||
"EmptyFiles": "4.4.0"
|
||||
}
|
||||
},
|
||||
"xunit.v3": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.2.2, )",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
|
||||
"dependencies": {
|
||||
"xunit.v3.mtp-v1": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"Castle.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.1.1",
|
||||
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
|
||||
"dependencies": {
|
||||
"System.Diagnostics.EventLog": "6.0.0"
|
||||
}
|
||||
},
|
||||
"DiffEngine": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.0",
|
||||
"contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==",
|
||||
"dependencies": {
|
||||
"EmptyFiles": "4.4.0",
|
||||
"System.Management": "6.0.1"
|
||||
}
|
||||
},
|
||||
"EmptyFiles": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.4.0",
|
||||
"contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw=="
|
||||
},
|
||||
"Microsoft.ApplicationInsights": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
|
||||
},
|
||||
"Microsoft.Bcl.AsyncInterfaces": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
|
||||
},
|
||||
"Microsoft.Testing.Extensions.Telemetry": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.ApplicationInsights": "2.23.0",
|
||||
"Microsoft.Testing.Platform": "1.9.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.Testing.Extensions.TrxReport.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Platform": "1.9.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.Testing.Platform": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
|
||||
},
|
||||
"Microsoft.Testing.Platform.MSBuild": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Platform": "1.9.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.Win32.Registry": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.0.0",
|
||||
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
|
||||
},
|
||||
"System.CodeDom": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
|
||||
},
|
||||
"System.Diagnostics.EventLog": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw=="
|
||||
},
|
||||
"System.Management": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.1",
|
||||
"contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==",
|
||||
"dependencies": {
|
||||
"System.CodeDom": "6.0.0"
|
||||
}
|
||||
},
|
||||
"xunit.analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.27.0",
|
||||
"contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
|
||||
},
|
||||
"xunit.v3.assert": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
|
||||
},
|
||||
"xunit.v3.common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Bcl.AsyncInterfaces": "6.0.0"
|
||||
}
|
||||
},
|
||||
"xunit.v3.core.mtp-v1": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Extensions.Telemetry": "1.9.1",
|
||||
"Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
|
||||
"Microsoft.Testing.Platform": "1.9.1",
|
||||
"Microsoft.Testing.Platform.MSBuild": "1.9.1",
|
||||
"xunit.v3.extensibility.core": "[3.2.2]",
|
||||
"xunit.v3.runner.inproc.console": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.extensibility.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
|
||||
"dependencies": {
|
||||
"xunit.v3.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.mtp-v1": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
|
||||
"dependencies": {
|
||||
"xunit.analyzers": "1.27.0",
|
||||
"xunit.v3.assert": "[3.2.2]",
|
||||
"xunit.v3.core.mtp-v1": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.runner.common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Win32.Registry": "[5.0.0]",
|
||||
"xunit.v3.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.runner.inproc.console": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
|
||||
"dependencies": {
|
||||
"xunit.v3.extensibility.core": "[3.2.2]",
|
||||
"xunit.v3.runner.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Storage.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A migrated, unlocked cache for one test.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each harness gets its own in-memory database, so tests cannot interfere and can run in parallel.
|
||||
/// The Argon2id cost is deliberately far below the shipped profile — 8 MiB and one pass rather than
|
||||
/// 256 MiB and four. The stretching is what makes a stolen wrap expensive to attack, and none of these
|
||||
/// tests attack one; paying 320 ms per test to prove nothing would only encourage sharing state
|
||||
/// between them.
|
||||
/// </remarks>
|
||||
internal sealed class CacheHarness : IDisposable
|
||||
{
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private readonly MasterKey master;
|
||||
|
||||
private CacheHarness(ClientCacheFactory factory, MasterKey master, LocalCacheProtector protector)
|
||||
{
|
||||
Factory = factory;
|
||||
this.master = master;
|
||||
Protector = protector;
|
||||
|
||||
Items = new ItemStore(factory, protector);
|
||||
Outbox = new OutboxStore(factory, protector, TimeProvider.System);
|
||||
Vaults = new VaultStore(factory, TimeProvider.System);
|
||||
Unlock = new UnlockStore(factory, TimeProvider.System);
|
||||
SyncState = new SyncStateStore(factory);
|
||||
Conflicts = new ConflictStore(factory, protector, TimeProvider.System);
|
||||
}
|
||||
|
||||
internal static Guid VaultId { get; } = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0f");
|
||||
|
||||
internal static Guid UserId { get; } = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0f");
|
||||
|
||||
internal ClientCacheFactory Factory { get; }
|
||||
|
||||
internal LocalCacheProtector Protector { get; }
|
||||
|
||||
internal ItemStore Items { get; }
|
||||
|
||||
internal OutboxStore Outbox { get; }
|
||||
|
||||
internal VaultStore Vaults { get; }
|
||||
|
||||
internal UnlockStore Unlock { get; }
|
||||
|
||||
internal SyncStateStore SyncState { get; }
|
||||
|
||||
internal ConflictStore Conflicts { get; }
|
||||
|
||||
internal static async Task<CacheHarness> CreateAsync(
|
||||
string passphrase = "correct horse battery staple")
|
||||
{
|
||||
var factory = ClientCacheFactory.ForMemory($"cache-{Guid.CreateVersion7():N}");
|
||||
|
||||
try
|
||||
{
|
||||
await factory.MigrateAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var salt = new byte[CryptoSpec.SaltSize];
|
||||
var derived = MasterKey.Derive(passphrase, salt, CheapProfile);
|
||||
|
||||
return new CacheHarness(factory, derived, LocalCacheProtector.From(derived));
|
||||
}
|
||||
catch
|
||||
{
|
||||
factory.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Protector.Dispose();
|
||||
master.Dispose();
|
||||
Factory.Dispose();
|
||||
}
|
||||
|
||||
// ---- Builders ----
|
||||
|
||||
internal static EncryptedPayload Payload(byte seed = 1, uint keyGeneration = 1) =>
|
||||
new(
|
||||
Envelope: [seed, (byte)(seed + 1), (byte)(seed + 2)],
|
||||
WrappedDataKey: [(byte)(seed + 10), (byte)(seed + 11)],
|
||||
DataKeyId: Guid.Parse($"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e{seed:x2}"),
|
||||
KeyGeneration: keyGeneration,
|
||||
AadVersion: CryptoSpec.CurrentAadVersion);
|
||||
|
||||
internal static StoredItem Item(
|
||||
Guid entityId,
|
||||
int version = 1,
|
||||
long changeSequence = 1,
|
||||
byte seed = 1,
|
||||
bool deleted = false,
|
||||
SyncPlaintextFields? fields = null) =>
|
||||
new(
|
||||
VaultId,
|
||||
SyncEntityType.Host,
|
||||
entityId,
|
||||
version,
|
||||
changeSequence,
|
||||
deleted ? null : Payload(seed),
|
||||
deleted ? null : fields ?? new SyncPlaintextFields(),
|
||||
deleted,
|
||||
DateTimeOffset.FromUnixTimeSeconds(1_750_000_000 + changeSequence));
|
||||
|
||||
internal static QueuedChange Change(
|
||||
Guid entityId,
|
||||
SyncOperation operation = SyncOperation.Upsert,
|
||||
int? expectedVersion = null,
|
||||
byte seed = 1,
|
||||
StoredAncestor? ancestor = null,
|
||||
SyncPlaintextFields? fields = null) =>
|
||||
new(
|
||||
VaultId,
|
||||
SyncEntityType.Host,
|
||||
entityId,
|
||||
operation,
|
||||
expectedVersion,
|
||||
operation == SyncOperation.Delete ? null : Payload(seed),
|
||||
operation == SyncOperation.Delete ? null : fields ?? new SyncPlaintextFields(),
|
||||
ancestor);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static DodoSSH.Client.Storage.Tests.CacheHarness;
|
||||
|
||||
namespace DodoSSH.Client.Storage.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The item mirror, the vault list, the sync cursor, and the conflict log.
|
||||
/// </summary>
|
||||
public sealed class CacheStoreTests : IAsyncLifetime
|
||||
{
|
||||
private CacheHarness harness = null!;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync() => harness = await CreateAsync();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
harness.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- Items ----
|
||||
|
||||
[Fact]
|
||||
public async Task AnItem_RoundTripsItsCiphertextByteForByte()
|
||||
{
|
||||
// Not "equivalent" — identical. The AAD binds the row, so re-encrypting locally would work but
|
||||
// would throw away the ability to notice the server handing back bytes it should not have.
|
||||
var entityId = Guid.CreateVersion7();
|
||||
var item = Item(entityId, version: 4, changeSequence: 17, seed: 3);
|
||||
|
||||
await harness.Items.SaveAsync(item, Token);
|
||||
|
||||
var read = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token);
|
||||
|
||||
read.ShouldNotBeNull();
|
||||
read.Version.ShouldBe(4);
|
||||
read.ChangeSequence.ShouldBe(17);
|
||||
read.Payload.ShouldNotBeNull();
|
||||
read.Payload.Envelope.ShouldBe(item.Payload!.Envelope);
|
||||
read.Payload.WrappedDataKey.ShouldBe(item.Payload.WrappedDataKey);
|
||||
read.Payload.DataKeyId.ShouldBe(item.Payload.DataKeyId);
|
||||
read.Payload.KeyGeneration.ShouldBe(item.Payload.KeyGeneration);
|
||||
read.Payload.AadVersion.ShouldBe(item.Payload.AadVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePlaintextFields_RoundTripAndAreNotReadableInTheDatabase()
|
||||
{
|
||||
// The server has to hold a relay-enabled host's address in the clear because it resolves it.
|
||||
// This machine already holds the key that opens the payload, so leaving the address readable in
|
||||
// a file that ends up in a backup buys nothing.
|
||||
var entityId = Guid.CreateVersion7();
|
||||
var fields = new SyncPlaintextFields(
|
||||
RelayEnabled: true, Hostname: "bastion.internal", Port: 2222);
|
||||
|
||||
await harness.Items.SaveAsync(Item(entityId, fields: fields), Token);
|
||||
|
||||
var read = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token);
|
||||
read!.Fields.ShouldBe(fields);
|
||||
|
||||
var stored = await ReadRawFieldsAsync(entityId);
|
||||
stored.ShouldNotBeNull();
|
||||
|
||||
System.Text.Encoding.UTF8.GetString(stored)
|
||||
.Contains("bastion.internal", StringComparison.Ordinal)
|
||||
.ShouldBeFalse("the hostname is stored in the clear");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ACacheRecord_CannotBeMovedToAnotherRow()
|
||||
{
|
||||
// Why the record is bound to its own row rather than only to the user. Swapping two rows would
|
||||
// otherwise point one host's connection at another host's address.
|
||||
var mine = Guid.CreateVersion7();
|
||||
var other = Guid.CreateVersion7();
|
||||
|
||||
var sealedFields = harness.Protector.Protect(
|
||||
CryptoSpec.AadResourceType.Host,
|
||||
mine,
|
||||
PlaintextFieldsCodec.Encode(new SyncPlaintextFields(true, "mine.internal", 22)));
|
||||
|
||||
harness.Protector
|
||||
.TryUnprotect(CryptoSpec.AadResourceType.Host, other, sealedFields)
|
||||
.ShouldBeNull();
|
||||
|
||||
harness.Protector
|
||||
.TryUnprotect(CryptoSpec.AadResourceType.Credential, mine, sealedFields)
|
||||
.ShouldBeNull();
|
||||
|
||||
harness.Protector
|
||||
.TryUnprotect(CryptoSpec.AadResourceType.Host, mine, sealedFields)
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARecordSealedUnderAnotherPassphrase_DoesNotOpen()
|
||||
{
|
||||
var entityId = Guid.CreateVersion7();
|
||||
|
||||
using var stranger = await CreateAsync(passphrase: "a completely different passphrase");
|
||||
|
||||
var sealedFields = stranger.Protector.Protect(
|
||||
CryptoSpec.AadResourceType.Host, entityId, [1, 2, 3]);
|
||||
|
||||
harness.Protector
|
||||
.TryUnprotect(CryptoSpec.AadResourceType.Host, entityId, sealedFields)
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavingTwice_ReplacesRatherThanDuplicates()
|
||||
{
|
||||
var entityId = Guid.CreateVersion7();
|
||||
|
||||
await harness.Items.SaveAsync(Item(entityId, version: 1, seed: 1), Token);
|
||||
await harness.Items.SaveAsync(Item(entityId, version: 2, seed: 9), Token);
|
||||
|
||||
var items = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, false, Token);
|
||||
|
||||
items.ShouldHaveSingleItem().Version.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATombstone_IsHiddenFromTheListButStillFindable()
|
||||
{
|
||||
// The interface must not show a deleted host. The sync engine must still be able to tell a
|
||||
// deleted item from one it has never seen — a row that simply vanished is indistinguishable
|
||||
// from the latter, and would silently reappear.
|
||||
var entityId = Guid.CreateVersion7();
|
||||
|
||||
await harness.Items.SaveAsync(Item(entityId, version: 1), Token);
|
||||
await harness.Items.SaveAsync(Item(entityId, version: 2, deleted: true), Token);
|
||||
|
||||
(await harness.Items.ListAsync(VaultId, SyncEntityType.Host, false, Token)).ShouldBeEmpty();
|
||||
|
||||
var withDeleted = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token);
|
||||
withDeleted.ShouldHaveSingleItem().IsDeleted.ShouldBeTrue();
|
||||
|
||||
var found = await harness.Items.FindAsync(VaultId, SyncEntityType.Host, entityId, Token);
|
||||
found.ShouldNotBeNull();
|
||||
found.IsDeleted.ShouldBeTrue();
|
||||
found.Payload.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CollectingTombstones_LeavesLiveItemsAlone()
|
||||
{
|
||||
var live = Guid.CreateVersion7();
|
||||
var dead = Guid.CreateVersion7();
|
||||
|
||||
await harness.Items.SaveAsync(Item(live, changeSequence: 1), Token);
|
||||
await harness.Items.SaveAsync(Item(dead, changeSequence: 2, deleted: true), Token);
|
||||
|
||||
var cutoff = DateTimeOffset.FromUnixTimeSeconds(1_750_000_100);
|
||||
var collected = await harness.Items.CollectTombstonesAsync(VaultId, cutoff, Token);
|
||||
|
||||
collected.ShouldBe(1);
|
||||
|
||||
var remaining = await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token);
|
||||
remaining.ShouldHaveSingleItem().EntityId.ShouldBe(live);
|
||||
}
|
||||
|
||||
// ---- Vaults ----
|
||||
|
||||
[Fact]
|
||||
public async Task ReplacingTheVaultList_AddsUpdatesAndRemoves()
|
||||
{
|
||||
var keep = Guid.CreateVersion7();
|
||||
var drop = Guid.CreateVersion7();
|
||||
|
||||
await harness.Vaults.ReplaceAllAsync(
|
||||
[Vault(keep, "Personal", 1), Vault(drop, "Old team", 1)], Token);
|
||||
|
||||
await harness.Vaults.ReplaceAllAsync([Vault(keep, "Renamed", 2)], Token);
|
||||
|
||||
var vaults = await harness.Vaults.ListAsync(Token);
|
||||
var only = vaults.ShouldHaveSingleItem();
|
||||
|
||||
only.VaultId.ShouldBe(keep);
|
||||
only.Name.ShouldBe("Renamed");
|
||||
only.KeyGeneration.ShouldBe(2u);
|
||||
(await harness.Vaults.FindAsync(drop, Token)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AVaultsWrappedKey_IsCachedSoAnOfflineLaunchCanDecrypt()
|
||||
{
|
||||
// Without this, an offline start could unlock the identity bundle and still not open a single
|
||||
// item.
|
||||
var vaultId = Guid.CreateVersion7();
|
||||
byte[] wrapped = [9, 9, 9, 9];
|
||||
|
||||
await harness.Vaults.ReplaceAllAsync(
|
||||
[Vault(vaultId, "Personal", 1) with { WrappedVaultKey = wrapped }], Token);
|
||||
|
||||
var read = await harness.Vaults.FindAsync(vaultId, Token);
|
||||
read!.WrappedVaultKey.ShouldBe(wrapped);
|
||||
}
|
||||
|
||||
// ---- Unlock material ----
|
||||
|
||||
[Fact]
|
||||
public async Task TheUnlockMaterial_SurvivesTheContextThatWroteIt()
|
||||
{
|
||||
// The offline unlock story, asserted rather than assumed: a fresh store over the same database
|
||||
// reads back the salt and the wrapped bundle with no network involved.
|
||||
var material = Material();
|
||||
|
||||
await harness.Unlock.SaveAsync(material, Token);
|
||||
|
||||
var reader = new UnlockStore(harness.Factory, TimeProvider.System);
|
||||
var read = await reader.ReadAsync(Token);
|
||||
|
||||
read.ShouldNotBeNull();
|
||||
read.UserId.ShouldBe(material.UserId);
|
||||
read.WrappedPrivateKey.ShouldBe(material.WrappedPrivateKey);
|
||||
read.KdfParameters.Salt.ShouldBe(material.KdfParameters.Salt);
|
||||
read.KdfParameters.MemoryKibibytes.ShouldBe(material.KdfParameters.MemoryKibibytes);
|
||||
read.KdfParameters.Passes.ShouldBe(material.KdfParameters.Passes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavingTheUnlockMaterialTwice_UpdatesTheSingleRow()
|
||||
{
|
||||
await harness.Unlock.SaveAsync(Material(), Token);
|
||||
await harness.Unlock.SaveAsync(Material() with { Email = "changed@example.com" }, Token);
|
||||
|
||||
var read = await harness.Unlock.ReadAsync(Token);
|
||||
read!.Email.ShouldBe("changed@example.com");
|
||||
|
||||
(await CountUnlockRowsAsync()).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnotherUsersMaterial_IsRefusedRatherThanMixedIn()
|
||||
{
|
||||
// Adopting it would offer an unlock prompt whose passphrase can never work, and would mix one
|
||||
// user's items into another's vault list.
|
||||
await harness.Unlock.SaveAsync(Material(), Token);
|
||||
|
||||
var other = Material() with { UserId = Guid.CreateVersion7() };
|
||||
|
||||
await Should.ThrowAsync<CacheIdentityMismatchException>(
|
||||
async () => await harness.Unlock.SaveAsync(other, Token));
|
||||
}
|
||||
|
||||
// ---- Sync state ----
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnknownVault_ReadsAsStartingFromTheBeginning()
|
||||
{
|
||||
// Not an error. A null cursor is exactly right for a vault this client has not synced, and is
|
||||
// also the recovery path for a cache that had to be discarded.
|
||||
var state = await harness.SyncState.ReadAsync(Guid.CreateVersion7(), Token);
|
||||
|
||||
state.Cursor.ShouldBeNull();
|
||||
state.KeyGeneration.ShouldBe(0u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheCursor_IsStoredVerbatim()
|
||||
{
|
||||
// Opaque and integrity-tagged. A client that adjusted one could ask to resume from a position
|
||||
// the server never granted; storing it untouched is the only correct handling.
|
||||
const string Cursor = "v1.aGVsbG8gd29ybGQ.c2lnbmF0dXJl";
|
||||
|
||||
await harness.SyncState.SaveAsync(new StoredSyncState(VaultId, Cursor, 3), Token);
|
||||
|
||||
var read = await harness.SyncState.ReadAsync(VaultId, Token);
|
||||
read.Cursor.ShouldBe(Cursor);
|
||||
read.KeyGeneration.ShouldBe(3u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResettingAVault_DropsItsItemsButKeepsTheOutbox()
|
||||
{
|
||||
// Local unpushed changes are the only copy of the user's work. Clearing them along with the
|
||||
// cache would turn a recoverable cache problem into lost work.
|
||||
var entityId = Guid.CreateVersion7();
|
||||
|
||||
await harness.Items.SaveAsync(Item(entityId), Token);
|
||||
await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token);
|
||||
await harness.SyncState.SaveAsync(new StoredSyncState(VaultId, "cursor", 1), Token);
|
||||
|
||||
await harness.SyncState.ResetAsync(VaultId, Token);
|
||||
|
||||
(await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token)).ShouldBeEmpty();
|
||||
(await harness.SyncState.ReadAsync(VaultId, Token)).Cursor.ShouldBeNull();
|
||||
(await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
// ---- Conflicts ----
|
||||
|
||||
[Fact]
|
||||
public async Task AConflictDetail_RoundTripsAndIsSealedAtRest()
|
||||
{
|
||||
// This is the one place the cache holds decrypted vault content on purpose: the value a merge
|
||||
// displaced. It has to be readable to be useful, and it is as sensitive as the item it came
|
||||
// from.
|
||||
var entityId = Guid.CreateVersion7();
|
||||
var detail = System.Text.Encoding.UTF8.GetBytes("""{"field":"Notes","discarded":"my secret"}""");
|
||||
|
||||
var id = await harness.Conflicts.RecordAsync(
|
||||
VaultId, SyncEntityType.Host, entityId, ConflictKind.FieldOverridden, detail, Token);
|
||||
|
||||
var listed = (await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldHaveSingleItem();
|
||||
listed.Id.ShouldBe(id);
|
||||
listed.Kind.ShouldBe(ConflictKind.FieldOverridden);
|
||||
listed.Detail.ShouldBe(detail);
|
||||
|
||||
var raw = await ReadRawConflictDetailAsync(id);
|
||||
System.Text.Encoding.UTF8.GetString(raw!)
|
||||
.Contains("my secret", StringComparison.Ordinal)
|
||||
.ShouldBeFalse("the discarded value is stored in the clear");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnAcknowledgedConflict_LeavesTheListButKeepsTheValue()
|
||||
{
|
||||
// Someone who dismisses a warning and realises a minute later that they wanted the other value
|
||||
// should still be able to get it.
|
||||
var id = await harness.Conflicts.RecordAsync(
|
||||
VaultId, SyncEntityType.Host, Guid.CreateVersion7(), ConflictKind.FieldOverridden,
|
||||
new byte[] { 1, 2, 3 }, Token);
|
||||
|
||||
(await harness.Conflicts.AcknowledgeAsync(id, Token)).ShouldBeTrue();
|
||||
|
||||
(await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldBeEmpty();
|
||||
|
||||
var all = (await harness.Conflicts.ListAsync(VaultId, true, Token)).ShouldHaveSingleItem();
|
||||
all.Detail.ShouldBe(new byte[] { 1, 2, 3 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnacknowledgedConflict_CannotBeDiscarded()
|
||||
{
|
||||
var id = await harness.Conflicts.RecordAsync(
|
||||
VaultId, SyncEntityType.Host, Guid.CreateVersion7(), ConflictKind.Undecryptable,
|
||||
new byte[] { 1 }, Token);
|
||||
|
||||
(await harness.Conflicts.DiscardAsync(id, Token)).ShouldBeFalse();
|
||||
|
||||
await harness.Conflicts.AcknowledgeAsync(id, Token);
|
||||
(await harness.Conflicts.DiscardAsync(id, Token)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
private static StoredVault Vault(Guid vaultId, string name, uint keyGeneration) =>
|
||||
new(vaultId, name, IsPersonal: true, TeamId: null, keyGeneration, Permissions: 31,
|
||||
WrappedVaultKey: [1, 2, 3], RekeyRequired: false);
|
||||
|
||||
private static StoredUnlockMaterial Material() =>
|
||||
new(
|
||||
"https://dodossh.example",
|
||||
UserId,
|
||||
"https://idp.example",
|
||||
"alice",
|
||||
"alice@example.com",
|
||||
"Alice",
|
||||
KeyGeneration: 1,
|
||||
WrappedPrivateKey: [4, 5, 6, 7],
|
||||
new KdfParameters("argon2id", [8, 9, 10, 11], 262144, 4, 1),
|
||||
DateTimeOffset.FromUnixTimeSeconds(1_750_000_000));
|
||||
|
||||
private async Task<byte[]?> ReadRawFieldsAsync(Guid entityId)
|
||||
{
|
||||
var context = harness.Factory.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
return await context.Set<CachedItemRow>()
|
||||
.Where(row => row.EntityId == entityId)
|
||||
.Select(row => row.ProtectedFields)
|
||||
.SingleAsync(Token);
|
||||
}
|
||||
|
||||
private async Task<byte[]?> ReadRawConflictDetailAsync(Guid id)
|
||||
{
|
||||
var context = harness.Factory.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
return await context.Set<ConflictRow>()
|
||||
.Where(row => row.Id == id)
|
||||
.Select(row => row.Detail)
|
||||
.SingleAsync(Token);
|
||||
}
|
||||
|
||||
private async Task<int> CountUnlockRowsAsync()
|
||||
{
|
||||
var context = harness.Factory.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
return await context.Set<UnlockMaterialRow>().CountAsync(Token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Against real SQLite, in memory. Not an in-memory *provider*: the point of these tests is that the
|
||||
schema, the composite keys and the unique index behave as configured, none of which the in-memory
|
||||
provider enforces. An in-memory SQLite file exercises the actual query pipeline and still runs in
|
||||
milliseconds.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,286 @@
|
||||
using DodoSSH.Contracts;
|
||||
using static DodoSSH.Client.Storage.Tests.CacheHarness;
|
||||
|
||||
namespace DodoSSH.Client.Storage.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The outbox, whose coalescing rules are where offline work is kept or lost.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two properties carry the weight. The ancestor must survive every coalesce, or a conflict can only
|
||||
/// be arbitrated rather than merged. And a coalesced row must get a fresh operation id, or the server
|
||||
/// can answer <c>Duplicate</c> for an operation whose contents have since changed and silently discard
|
||||
/// the newer edit.
|
||||
/// </remarks>
|
||||
public sealed class OutboxStoreTests : IAsyncLifetime
|
||||
{
|
||||
private CacheHarness harness = null!;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync() => harness = await CreateAsync();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
harness.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AQueuedChange_ComesBackWithEverythingItNeedsToBePushed()
|
||||
{
|
||||
var entityId = Guid.CreateVersion7();
|
||||
var ancestor = new StoredAncestor(3, Payload(seed: 40), new SyncPlaintextFields());
|
||||
|
||||
var queued = await harness.Outbox.QueueAsync(
|
||||
Change(entityId, expectedVersion: 3, seed: 7, ancestor: ancestor), Token);
|
||||
|
||||
queued.OperationId.ShouldNotBe(Guid.Empty);
|
||||
queued.ExpectedVersion.ShouldBe(3);
|
||||
queued.Operation.ShouldBe(SyncOperation.Upsert);
|
||||
queued.Payload.ShouldNotBeNull();
|
||||
queued.Payload.Envelope.ShouldBe(Payload(seed: 7).Envelope);
|
||||
queued.Payload.WrappedDataKey.ShouldBe(Payload(seed: 7).WrappedDataKey);
|
||||
queued.Payload.DataKeyId.ShouldBe(Payload(seed: 7).DataKeyId);
|
||||
queued.Ancestor.ShouldNotBeNull();
|
||||
queued.Ancestor.Version.ShouldBe(3);
|
||||
queued.Ancestor.Payload.Envelope.ShouldBe(Payload(seed: 40).Envelope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASecondEditToTheSameItem_CoalescesIntoOneRow()
|
||||
{
|
||||
// Two rows would have to be pushed in order, and the second's expectedVersion is the version
|
||||
// the first will produce — which is not known when it is queued.
|
||||
var entityId = Guid.CreateVersion7();
|
||||
|
||||
await harness.Outbox.QueueAsync(Change(entityId, seed: 1), Token);
|
||||
await harness.Outbox.QueueAsync(Change(entityId, seed: 2), Token);
|
||||
|
||||
var pending = await harness.Outbox.TakeAsync(VaultId, 10, Token);
|
||||
|
||||
pending.ShouldHaveSingleItem().Payload!.Envelope.ShouldBe(Payload(seed: 2).Envelope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ACoalescedEdit_KeepsTheOriginalAncestorAndExpectedVersion()
|
||||
{
|
||||
// The load-bearing rule. The newest state is still a descendant of the base the first edit
|
||||
// branched from; adopting the caller's values here would discard the common ancestor after the
|
||||
// first edit and leave nothing to merge against.
|
||||
var entityId = Guid.CreateVersion7();
|
||||
var ancestor = new StoredAncestor(5, Payload(seed: 90), new SyncPlaintextFields());
|
||||
|
||||
await harness.Outbox.QueueAsync(
|
||||
Change(entityId, expectedVersion: 5, seed: 1, ancestor: ancestor), Token);
|
||||
|
||||
// A second edit arrives knowing nothing about the base.
|
||||
await harness.Outbox.QueueAsync(
|
||||
Change(entityId, expectedVersion: null, seed: 2, ancestor: null), Token);
|
||||
|
||||
var pending = (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
|
||||
|
||||
pending.ExpectedVersion.ShouldBe(5);
|
||||
pending.Ancestor.ShouldNotBeNull();
|
||||
pending.Ancestor.Version.ShouldBe(5);
|
||||
pending.Ancestor.Payload.Envelope.ShouldBe(Payload(seed: 90).Envelope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ACoalescedEdit_GetsAFreshOperationId()
|
||||
{
|
||||
// Reusing the id would let the server report Duplicate — meaning "already applied" — for an
|
||||
// operation whose payload has since changed, and the newer edit would vanish with the push
|
||||
// reported as a success.
|
||||
var entityId = Guid.CreateVersion7();
|
||||
|
||||
var first = await harness.Outbox.QueueAsync(Change(entityId, seed: 1), Token);
|
||||
await harness.Outbox.MarkDispatchedAsync(first.Sequence, Token);
|
||||
|
||||
var second = await harness.Outbox.QueueAsync(Change(entityId, seed: 2), Token);
|
||||
|
||||
second.OperationId.ShouldNotBe(first.OperationId);
|
||||
second.Sequence.ShouldBe(first.Sequence);
|
||||
|
||||
// And the retry counter resets, because this is a new operation rather than a further attempt
|
||||
// at the old one.
|
||||
second.Attempts.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUpsertFollowedByADelete_BecomesADelete()
|
||||
{
|
||||
var entityId = Guid.CreateVersion7();
|
||||
|
||||
await harness.Outbox.QueueAsync(Change(entityId, expectedVersion: 2, seed: 1), Token);
|
||||
await harness.Outbox.QueueAsync(
|
||||
Change(entityId, SyncOperation.Delete, expectedVersion: 2), Token);
|
||||
|
||||
var pending = (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
|
||||
|
||||
pending.Operation.ShouldBe(SyncOperation.Delete);
|
||||
pending.Payload.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangesToDifferentItems_DrainInTheOrderTheyWereMade()
|
||||
{
|
||||
// Order matters for creates that reference each other — a host naming a jump host — so the
|
||||
// outbox is a queue, not a set.
|
||||
var first = Guid.CreateVersion7();
|
||||
var second = Guid.CreateVersion7();
|
||||
var third = Guid.CreateVersion7();
|
||||
|
||||
foreach (var id in new[] { first, second, third })
|
||||
{
|
||||
await harness.Outbox.QueueAsync(Change(id), Token);
|
||||
}
|
||||
|
||||
var pending = await harness.Outbox.TakeAsync(VaultId, 10, Token);
|
||||
|
||||
pending.Select(p => p.EntityId).ShouldBe([first, second, third]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CoalescingDoesNotJumpTheQueue()
|
||||
{
|
||||
// The row keeps its original position. Re-editing the first item should not push it behind
|
||||
// items queued after it, because the later ones may depend on it existing.
|
||||
var first = Guid.CreateVersion7();
|
||||
var second = Guid.CreateVersion7();
|
||||
|
||||
await harness.Outbox.QueueAsync(Change(first), Token);
|
||||
await harness.Outbox.QueueAsync(Change(second), Token);
|
||||
await harness.Outbox.QueueAsync(Change(first, seed: 9), Token);
|
||||
|
||||
var pending = await harness.Outbox.TakeAsync(VaultId, 10, Token);
|
||||
|
||||
pending.Select(p => p.EntityId).ShouldBe([first, second]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revise_MovesTheAncestorForwardUnlikeQueue()
|
||||
{
|
||||
// The opposite intent from a coalesce: a merge has just been performed against a newer server
|
||||
// version, so that version becomes the base. Leaving the old ancestor would make the re-push
|
||||
// conflict against the same point for ever.
|
||||
var entityId = Guid.CreateVersion7();
|
||||
var original = new StoredAncestor(1, Payload(seed: 10), new SyncPlaintextFields());
|
||||
|
||||
var queued = await harness.Outbox.QueueAsync(
|
||||
Change(entityId, expectedVersion: 1, ancestor: original), Token);
|
||||
|
||||
var merged = new StoredAncestor(4, Payload(seed: 20), new SyncPlaintextFields());
|
||||
|
||||
var revised = await harness.Outbox.ReviseAsync(
|
||||
queued.Sequence,
|
||||
SyncOperation.Upsert,
|
||||
expectedVersion: 4,
|
||||
Payload(seed: 30),
|
||||
new SyncPlaintextFields(),
|
||||
merged,
|
||||
Token);
|
||||
|
||||
revised.ShouldNotBeNull();
|
||||
revised.ExpectedVersion.ShouldBe(4);
|
||||
revised.Ancestor!.Version.ShouldBe(4);
|
||||
revised.Ancestor.Payload.Envelope.ShouldBe(Payload(seed: 20).Envelope);
|
||||
revised.OperationId.ShouldNotBe(queued.OperationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AParkedOperation_IsNotHandedOutForPushing()
|
||||
{
|
||||
// An operation the server called Invalid will never succeed. Retrying it would spin and, worse,
|
||||
// would block every change queued behind it in a vault the user can still write to.
|
||||
var parked = Guid.CreateVersion7();
|
||||
var healthy = Guid.CreateVersion7();
|
||||
|
||||
var queued = await harness.Outbox.QueueAsync(Change(parked), Token);
|
||||
await harness.Outbox.QueueAsync(Change(healthy), Token);
|
||||
|
||||
await harness.Outbox.ParkAsync(queued.Sequence, "Entity type not supported.", Token);
|
||||
|
||||
var pending = await harness.Outbox.TakeAsync(VaultId, 10, Token);
|
||||
pending.ShouldHaveSingleItem().EntityId.ShouldBe(healthy);
|
||||
|
||||
var listed = (await harness.Outbox.ListParkedAsync(VaultId, Token)).ShouldHaveSingleItem();
|
||||
listed.EntityId.ShouldBe(parked);
|
||||
listed.LastError.ShouldBe("Entity type not supported.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReEditingAParkedOperation_Unparks()
|
||||
{
|
||||
// The user's remedy for a rejected change is to change it. That has to actually re-arm it.
|
||||
var entityId = Guid.CreateVersion7();
|
||||
|
||||
var queued = await harness.Outbox.QueueAsync(Change(entityId), Token);
|
||||
await harness.Outbox.ParkAsync(queued.Sequence, "nope", Token);
|
||||
|
||||
var requeued = await harness.Outbox.QueueAsync(Change(entityId, seed: 5), Token);
|
||||
|
||||
requeued.IsParked.ShouldBeFalse();
|
||||
requeued.LastError.ShouldBeNull();
|
||||
(await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Complete_RemovesTheOperation()
|
||||
{
|
||||
var queued = await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token);
|
||||
|
||||
(await harness.Outbox.CompleteAsync(queued.Sequence, Token)).ShouldBeTrue();
|
||||
(await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldBeEmpty();
|
||||
|
||||
// Idempotent: a drain that retries after a crash must not fail on an already-cleared row.
|
||||
(await harness.Outbox.CompleteAsync(queued.Sequence, Token)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkDispatched_CountsAttempts()
|
||||
{
|
||||
var queued = await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token);
|
||||
|
||||
await harness.Outbox.MarkDispatchedAsync(queued.Sequence, Token);
|
||||
await harness.Outbox.MarkDispatchedAsync(queued.Sequence, Token);
|
||||
|
||||
var pending = (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
|
||||
pending.Attempts.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUpsertWithoutAPayload_IsRefused()
|
||||
{
|
||||
// Caught here rather than at the server, where it would come back as one opaque Invalid among
|
||||
// a batch of otherwise good operations.
|
||||
var change = new QueuedChange(
|
||||
VaultId,
|
||||
SyncEntityType.Host,
|
||||
Guid.CreateVersion7(),
|
||||
SyncOperation.Upsert,
|
||||
ExpectedVersion: null,
|
||||
Payload: null,
|
||||
Fields: null,
|
||||
Ancestor: null);
|
||||
|
||||
await Should.ThrowAsync<ArgumentException>(
|
||||
async () => await harness.Outbox.QueueAsync(change, Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePendingOperationForAnItem_CanBeLookedUpDirectly()
|
||||
{
|
||||
// How a pull discovers that an incoming change collides with local work.
|
||||
var entityId = Guid.CreateVersion7();
|
||||
await harness.Outbox.QueueAsync(Change(entityId), Token);
|
||||
|
||||
(await harness.Outbox.FindAsync(VaultId, SyncEntityType.Host, entityId, Token))
|
||||
.ShouldNotBeNull();
|
||||
|
||||
(await harness.Outbox.FindAsync(VaultId, SyncEntityType.Host, Guid.CreateVersion7(), Token))
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.134, )",
|
||||
"resolved": "3.0.134",
|
||||
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
|
||||
},
|
||||
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.0, )",
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==",
|
||||
"dependencies": {
|
||||
"Castle.Core": "5.1.1"
|
||||
}
|
||||
},
|
||||
"Shouldly": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.3.0, )",
|
||||
"resolved": "4.3.0",
|
||||
"contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==",
|
||||
"dependencies": {
|
||||
"DiffEngine": "11.3.0",
|
||||
"EmptyFiles": "4.4.0"
|
||||
}
|
||||
},
|
||||
"xunit.v3": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.2.2, )",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
|
||||
"dependencies": {
|
||||
"xunit.v3.mtp-v1": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"Castle.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.1.1",
|
||||
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
|
||||
"dependencies": {
|
||||
"System.Diagnostics.EventLog": "6.0.0"
|
||||
}
|
||||
},
|
||||
"DiffEngine": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.0",
|
||||
"contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==",
|
||||
"dependencies": {
|
||||
"EmptyFiles": "4.4.0",
|
||||
"System.Management": "6.0.1"
|
||||
}
|
||||
},
|
||||
"EmptyFiles": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.4.0",
|
||||
"contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw=="
|
||||
},
|
||||
"Microsoft.ApplicationInsights": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
|
||||
},
|
||||
"Microsoft.Bcl.AsyncInterfaces": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
|
||||
},
|
||||
"Microsoft.Data.Sqlite.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA=="
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q=="
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Sqlite.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "YbVWMIouzwTKBiLms8boa7xeRT88wI14R1msv3XExFk9n0/sa8nU7MwDa1CKtfLGMJs7O7QWuS9/xhcQ72AD2A==",
|
||||
"dependencies": {
|
||||
"Microsoft.Data.Sqlite.Core": "10.0.10",
|
||||
"Microsoft.EntityFrameworkCore.Relational": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.DependencyModel": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10",
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Caching.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Caching.Memory": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Caching.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Options": "10.0.10",
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA=="
|
||||
},
|
||||
"Microsoft.Extensions.Logging": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "10.0.10",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Options": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Options": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Primitives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ=="
|
||||
},
|
||||
"Microsoft.Testing.Extensions.Telemetry": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.ApplicationInsights": "2.23.0",
|
||||
"Microsoft.Testing.Platform": "1.9.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.Testing.Extensions.TrxReport.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Platform": "1.9.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.Testing.Platform": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
|
||||
},
|
||||
"Microsoft.Testing.Platform.MSBuild": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Platform": "1.9.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.Win32.Registry": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.0.0",
|
||||
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
|
||||
},
|
||||
"System.CodeDom": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
|
||||
},
|
||||
"System.Diagnostics.EventLog": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw=="
|
||||
},
|
||||
"System.Management": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.1",
|
||||
"contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==",
|
||||
"dependencies": {
|
||||
"System.CodeDom": "6.0.0"
|
||||
}
|
||||
},
|
||||
"xunit.analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.27.0",
|
||||
"contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
|
||||
},
|
||||
"xunit.v3.assert": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
|
||||
},
|
||||
"xunit.v3.common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Bcl.AsyncInterfaces": "6.0.0"
|
||||
}
|
||||
},
|
||||
"xunit.v3.core.mtp-v1": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Extensions.Telemetry": "1.9.1",
|
||||
"Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
|
||||
"Microsoft.Testing.Platform": "1.9.1",
|
||||
"Microsoft.Testing.Platform.MSBuild": "1.9.1",
|
||||
"xunit.v3.extensibility.core": "[3.2.2]",
|
||||
"xunit.v3.runner.inproc.console": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.extensibility.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
|
||||
"dependencies": {
|
||||
"xunit.v3.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.mtp-v1": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
|
||||
"dependencies": {
|
||||
"xunit.analyzers": "1.27.0",
|
||||
"xunit.v3.assert": "[3.2.2]",
|
||||
"xunit.v3.core.mtp-v1": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.runner.common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Win32.Registry": "[5.0.0]",
|
||||
"xunit.v3.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.runner.inproc.console": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
|
||||
"dependencies": {
|
||||
"xunit.v3.extensibility.core": "[3.2.2]",
|
||||
"xunit.v3.runner.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Contracts": "[1.0.0, )",
|
||||
"DodoSSH.Crypto": "[1.0.0, )",
|
||||
"EFCore.NamingConventions": "[10.0.1, )",
|
||||
"Microsoft.EntityFrameworkCore.Sqlite": "[10.0.10, )"
|
||||
}
|
||||
},
|
||||
"dodossh.contracts": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )"
|
||||
}
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.1, )",
|
||||
"resolved": "10.0.1",
|
||||
"contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)",
|
||||
"Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1"
|
||||
}
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.0.22, )",
|
||||
"resolved": "1.0.22",
|
||||
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.10",
|
||||
"Microsoft.EntityFrameworkCore.Analyzers": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Relational": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Sqlite": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "kzg9MuQNJvZQxAU+piSkEzc7/1tpW6n1nVSGGMObu2GgxLK8Nf+6fvZundaznTZ+O2KhfPZ8HFNCzMH3PWDUmA==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.DependencyModel": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10",
|
||||
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.11",
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[26.4.0, )",
|
||||
"resolved": "26.4.0",
|
||||
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
|
||||
"dependencies": {
|
||||
"libsodium": "[1.0.22, 1.0.23)"
|
||||
}
|
||||
},
|
||||
"SQLitePCLRaw.bundle_e_sqlite3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.lib.e_sqlite3": "2.1.12",
|
||||
"SQLitePCLRaw.provider.e_sqlite3": "2.1.12"
|
||||
}
|
||||
},
|
||||
"SQLitePCLRaw.core": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg=="
|
||||
},
|
||||
"SQLitePCLRaw.lib.e_sqlite3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
|
||||
},
|
||||
"SQLitePCLRaw.provider.e_sqlite3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
using DodoSSH.Client.Storage;
|
||||
using static DodoSSH.Client.Sync.Tests.SyncHarness;
|
||||
|
||||
namespace DodoSSH.Client.Sync.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Two machines, one vault, every way they can disagree.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The highest-value suite in the product, because this is the only place data can be lost. Every case
|
||||
/// asserts two things: that the two devices converge on the same state, and that whatever the merge had
|
||||
/// to override is recorded rather than gone. A merge that quietly drops the password someone just typed
|
||||
/// is worse than one that refuses to merge at all.
|
||||
/// </remarks>
|
||||
public sealed class ConflictMatrixTests : IAsyncLifetime
|
||||
{
|
||||
private SyncHarness harness = null!;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync() => harness = await CreateAsync();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
harness.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- The uncontested paths ----
|
||||
|
||||
[Fact]
|
||||
public async Task ACreatedHost_ReachesTheOtherMachine()
|
||||
{
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "primary"));
|
||||
|
||||
await harness.SettleAsync();
|
||||
|
||||
var seen = await harness.Second.FindAsync(entityId);
|
||||
seen.Host.Label.ShouldBe("prod-db");
|
||||
seen.Host.Notes.ShouldBe("primary");
|
||||
seen.HasUnsyncedChanges.ShouldBeFalse();
|
||||
harness.Server.RowCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AHostCreatedOffline_IsVisibleLocallyBeforeAnySync()
|
||||
{
|
||||
// The reason the outbox exists. A host typed in on a plane has to be usable on that plane.
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db"));
|
||||
|
||||
var local = await harness.First.FindAsync(entityId);
|
||||
local.Host.Label.ShouldBe("prod-db");
|
||||
local.HasUnsyncedChanges.ShouldBeTrue();
|
||||
|
||||
harness.Server.RowCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALocalOnlyEdit_IsPushed()
|
||||
{
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "rotate quarterly"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
(await harness.Second.FindAsync(entityId)).Host.Notes.ShouldBe("rotate quarterly");
|
||||
(await harness.First.ConflictsAsync()).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARemoteOnlyEdit_IsPulledWithoutAConflict()
|
||||
{
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
await harness.Second.UpdateAsync(entityId, Host("prod-db", username: "postgres"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
(await harness.First.FindAsync(entityId)).Host.Username.ShouldBe("postgres");
|
||||
(await harness.First.ConflictsAsync()).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- Both edited ----
|
||||
|
||||
[Fact]
|
||||
public async Task BothEditedDifferentFields_BothSurvive()
|
||||
{
|
||||
// The payoff for a field-level merge. Last-writer-wins would lose one of these.
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "from the laptop"));
|
||||
await harness.Second.UpdateAsync(entityId, Host("prod-db", username: "postgres"));
|
||||
|
||||
await harness.SettleAsync();
|
||||
|
||||
var merged = (await harness.First.FindAsync(entityId)).Host;
|
||||
merged.Notes.ShouldBe("from the laptop");
|
||||
merged.Username.ShouldBe("postgres");
|
||||
|
||||
(await harness.Second.FindAsync(entityId)).Host.ShouldBe(merged);
|
||||
(await harness.First.ConflictsAsync()).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BothAddedADifferentDirective_BothSurvive()
|
||||
{
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
await harness.First.UpdateAsync(
|
||||
entityId, Host("prod-db", options: [("Compression", "yes")]));
|
||||
await harness.Second.UpdateAsync(
|
||||
entityId, Host("prod-db", options: [("ServerAliveInterval", "30")]));
|
||||
|
||||
await harness.SettleAsync();
|
||||
|
||||
var merged = (await harness.First.FindAsync(entityId)).Host;
|
||||
merged.Options.Count.ShouldBe(2);
|
||||
merged.Options.TryGetValue("Compression", out _).ShouldBeTrue();
|
||||
merged.Options.TryGetValue("ServerAliveInterval", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BothEditedTheSameField_OneValueWinsAndTheOtherIsRecorded()
|
||||
{
|
||||
// A genuine clash. Whichever side loses, its value has to be retrievable — that is the entire
|
||||
// justification for resolving automatically instead of blocking.
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "original"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "from the laptop"));
|
||||
await harness.Second.UpdateAsync(entityId, Host("prod-db", notes: "from the desktop"));
|
||||
|
||||
await harness.SettleAsync();
|
||||
|
||||
var first = (await harness.First.FindAsync(entityId)).Host;
|
||||
var second = (await harness.Second.FindAsync(entityId)).Host;
|
||||
|
||||
first.ShouldBe(second);
|
||||
|
||||
var winner = first.Notes.ShouldNotBeNull();
|
||||
var lost = string.Equals(winner, "from the laptop", StringComparison.Ordinal)
|
||||
? "from the desktop"
|
||||
: "from the laptop";
|
||||
|
||||
// One of the two, and the same one on both machines. Which is not the point; that the other is
|
||||
// retrievable is.
|
||||
new[] { "from the laptop", "from the desktop" }
|
||||
.Contains(winner, StringComparer.Ordinal)
|
||||
.ShouldBeTrue();
|
||||
|
||||
(await ConflictsAcrossDevicesAsync())
|
||||
.ShouldContain(kind => kind == ConflictKind.FieldOverridden);
|
||||
|
||||
(await DiscardedValuesAsync())
|
||||
.ShouldContain(
|
||||
detail => detail.Contains(lost, StringComparison.Ordinal),
|
||||
"the overridden value must be recoverable from the conflict log");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BothMadeTheSameEdit_IsNotAConflict()
|
||||
{
|
||||
// Two people fixing the same typo must not be asked to arbitrate.
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db", hostname: "db.internl"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
await harness.First.UpdateAsync(entityId, Host("prod-db", hostname: "db.internal"));
|
||||
await harness.Second.UpdateAsync(entityId, Host("prod-db", hostname: "db.internal"));
|
||||
|
||||
await harness.SettleAsync();
|
||||
|
||||
(await harness.First.FindAsync(entityId)).Host.Hostname.ShouldBe("db.internal");
|
||||
(await ConflictsAcrossDevicesAsync()).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- Deletes ----
|
||||
|
||||
[Fact]
|
||||
public async Task ADeletedHost_DisappearsEverywhere()
|
||||
{
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
await harness.First.DeleteAsync(entityId);
|
||||
await harness.SettleAsync();
|
||||
|
||||
(await harness.First.ListAsync()).Hosts.ShouldBeEmpty();
|
||||
(await harness.Second.ListAsync()).Hosts.ShouldBeEmpty();
|
||||
harness.Server.RowCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BothDeleted_IsNotAConflict()
|
||||
{
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
await harness.First.DeleteAsync(entityId);
|
||||
await harness.Second.DeleteAsync(entityId);
|
||||
|
||||
await harness.SettleAsync();
|
||||
|
||||
(await harness.First.ListAsync()).Hosts.ShouldBeEmpty();
|
||||
(await harness.Second.ListAsync()).Hosts.ShouldBeEmpty();
|
||||
(await ConflictsAcrossDevicesAsync()).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletedElsewhereWhileEditedHere_TheLocalWorkSurvivesUnderANewName()
|
||||
{
|
||||
// The case where naive handling loses data outright. The tombstone has to stand — arguing with it
|
||||
// conflicts for ever — so the edit is preserved as a separate host instead of being dropped.
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
// The laptop syncs first, so its delete is what reaches the server; the desktop's edit is the
|
||||
// one that has to be rescued. Which side loses is decided by who gets there first, and both
|
||||
// orderings are covered — see EditedElsewhereWhileDeletedHere for the mirror image.
|
||||
await harness.First.DeleteAsync(entityId);
|
||||
await harness.Second.UpdateAsync(
|
||||
entityId, Host("prod-db", notes: "credentials rotated, do not delete"));
|
||||
|
||||
await harness.SettleAsync();
|
||||
|
||||
var listing = await harness.First.ListAsync();
|
||||
|
||||
var restored = listing.Hosts.ShouldHaveSingleItem();
|
||||
restored.EntityId.ShouldNotBe(entityId);
|
||||
restored.Host.Label.ShouldBe("prod-db (restored)");
|
||||
restored.Host.Notes.ShouldBe("credentials rotated, do not delete");
|
||||
|
||||
(await ConflictsAcrossDevicesAsync())
|
||||
.ShouldContain(kind => kind == ConflictKind.RemoteDeleteResurrected);
|
||||
|
||||
// And the other machine sees it too, so the rescue is not local-only.
|
||||
(await harness.Second.ListAsync()).Hosts.ShouldHaveSingleItem()
|
||||
.EntityId.ShouldBe(restored.EntityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplayingAPulledDeletion_DoesNotDuplicateTheRescuedCopy()
|
||||
{
|
||||
// Applying a pulled change is at-least-once — the cursor is saved after the page is applied — so
|
||||
// a whole page can arrive twice. This checks the replay is harmless end to end; the deterministic
|
||||
// id it relies on is pinned by ResurrectionIdTests.
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
await harness.First.DeleteAsync(entityId);
|
||||
await harness.Second.UpdateAsync(entityId, Host("prod-db", notes: "keep me"));
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
// Rewind the desktop's cursor, so the deletion arrives a second time and it tries to rescue the
|
||||
// same content twice.
|
||||
await harness.Second.SyncAsync();
|
||||
await harness.Second.SyncState.ResetAsync(VaultId, TestContext.Current.CancellationToken);
|
||||
await harness.Second.SyncAsync();
|
||||
|
||||
await harness.SettleAsync();
|
||||
|
||||
(await harness.First.ListAsync()).Hosts.Count.ShouldBe(1);
|
||||
harness.Server.RowCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditedElsewhereWhileDeletedHere_TheDeleteIsAbandonedAndReported()
|
||||
{
|
||||
// The mirror image, and resolved the same way round: an edit outlives a removal. Re-deleting
|
||||
// costs a click; a discarded edit may be the only copy of something.
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "still needed"));
|
||||
await harness.Second.DeleteAsync(entityId);
|
||||
|
||||
await harness.SettleAsync();
|
||||
|
||||
var survivor = await harness.First.FindAsync(entityId);
|
||||
survivor.Host.Notes.ShouldBe("still needed");
|
||||
|
||||
(await ConflictsAcrossDevicesAsync())
|
||||
.ShouldContain(kind => kind == ConflictKind.LocalDeleteOverridden);
|
||||
}
|
||||
|
||||
// ---- Ordering, retries and idempotence ----
|
||||
|
||||
[Fact]
|
||||
public async Task OfflineChanges_ArePushedInTheOrderTheyWereMade()
|
||||
{
|
||||
var first = await harness.First.CreateAsync(Host("a-bastion"));
|
||||
var second = await harness.First.CreateAsync(Host("b-database"));
|
||||
var third = await harness.First.CreateAsync(Host("c-cache"));
|
||||
|
||||
await harness.SettleAsync();
|
||||
|
||||
var order = (await harness.Second.ListAsync()).Hosts
|
||||
.OrderBy(host => host.Version)
|
||||
.ThenBy(host => host.Host.Label, StringComparer.Ordinal)
|
||||
.Select(host => host.EntityId)
|
||||
.ToArray();
|
||||
|
||||
order.ShouldBe([first, second, third], ignoreOrder: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASecondSyncPass_ChangesNothing()
|
||||
{
|
||||
// Idempotence, which is what makes re-reading a client's own writes a safe way to avoid the
|
||||
// cursor-gap hazard in the push response.
|
||||
await harness.First.CreateAsync(Host("prod-db"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
var before = await harness.First.HostsSortedAsync();
|
||||
var pushesBefore = harness.Server.PushCount;
|
||||
|
||||
var report = await harness.First.SyncAsync();
|
||||
|
||||
report.Pushed.ShouldBe(0);
|
||||
harness.Server.PushCount.ShouldBe(pushesBefore);
|
||||
(await harness.First.HostsSortedAsync()).ShouldBe(before);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnEditWhileAnEarlierPushIsUnacknowledged_KeepsTheNewerValueWithoutDuplicating()
|
||||
{
|
||||
// A create that reached the server and whose answer did not come back, followed by another edit.
|
||||
// The newer value has to win and there must be exactly one host afterwards. Two mechanisms keep
|
||||
// that true: the pull sees the server's row and re-bases the queued edit onto it, and a coalesced
|
||||
// row carries a fresh operation id so the server cannot answer Duplicate — "already applied" —
|
||||
// for an operation whose contents have since changed. The second is pinned directly by
|
||||
// OutboxStoreTests.ACoalescedEdit_GetsAFreshOperationId; here they are exercised together.
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "first"));
|
||||
|
||||
// The create lands on the server, but the acknowledgement never reaches the laptop.
|
||||
await PushBehindTheEnginesBackAsync(entityId);
|
||||
|
||||
await harness.First.UpdateAsync(entityId, Host("prod-db", notes: "second"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
harness.Server.RowCount.ShouldBe(1);
|
||||
(await harness.First.FindAsync(entityId)).Host.Notes.ShouldBe("second");
|
||||
(await harness.Second.FindAsync(entityId)).Host.Notes.ShouldBe("second");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AConcurrentWriteDuringAPush_IsNotSkipped()
|
||||
{
|
||||
// The cursor-gap hazard. The push response carries a cursor sitting after this client's own
|
||||
// changes; adopting it would skip anything another client committed at a lower sequence in the
|
||||
// window between this client's pull and its push. The engine keeps its own cursor instead.
|
||||
var mine = await harness.First.CreateAsync(Host("mine"));
|
||||
|
||||
var theirs = Guid.CreateVersion7();
|
||||
harness.Server.OnPush = () => harness.Server.ExternalUpsert(theirs, ForeignPayload(), null);
|
||||
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
var ids = (await harness.First.Items
|
||||
.ListAsync(VaultId, Contracts.SyncEntityType.Host, false, TestContext.Current.CancellationToken))
|
||||
.Select(item => item.EntityId)
|
||||
.ToArray();
|
||||
|
||||
ids.ShouldContain(mine);
|
||||
ids.ShouldContain(theirs, "a change committed during the push was skipped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AForbiddenWrite_IsParkedRatherThanRetriedForever()
|
||||
{
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db"));
|
||||
|
||||
harness.Server.DenyWrites = true;
|
||||
var report = await harness.First.SyncAsync();
|
||||
|
||||
report.Parked.ShouldBe(1);
|
||||
(await harness.First.Outbox.ListParkedAsync(VaultId, TestContext.Current.CancellationToken))
|
||||
.ShouldHaveSingleItem().EntityId.ShouldBe(entityId);
|
||||
|
||||
// A parked operation is not retried, so a second pass sends nothing.
|
||||
var pushes = harness.Server.PushCount;
|
||||
await harness.First.SyncAsync();
|
||||
harness.Server.PushCount.ShouldBe(pushes);
|
||||
|
||||
(await harness.First.ConflictsAsync()).ShouldContain(c => c.Kind == ConflictKind.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AParkedChange_IsStillWhatTheUserSees()
|
||||
{
|
||||
// Hiding it because the server refused would show the old values and look like the edit was lost.
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db", notes: "mine"));
|
||||
|
||||
harness.Server.DenyWrites = true;
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
var host = await harness.First.FindAsync(entityId);
|
||||
host.Host.Notes.ShouldBe("mine");
|
||||
host.IsBlocked.ShouldBeTrue();
|
||||
host.HasUnsyncedChanges.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARekeyedVault_IsReportedRatherThanShowingAnEmptyList()
|
||||
{
|
||||
// After a rekey this client's grant is stale, so items pulled meanwhile cannot be read. Silently
|
||||
// showing nothing would look exactly like an empty vault.
|
||||
await harness.First.CreateAsync(Host("prod-db"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
harness.Server.KeyGeneration = 2;
|
||||
|
||||
var report = await harness.First.SyncAsync();
|
||||
|
||||
report.RekeyRequired.ShouldBeTrue();
|
||||
report.ServerKeyGeneration.ShouldBe(2u);
|
||||
report.NeedsAttention.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---- The overall property ----
|
||||
|
||||
[Fact]
|
||||
public async Task AfterAnInterleavedSession_BothMachinesAgree()
|
||||
{
|
||||
// Convergence, over a fixed script that exercises creates, edits, a delete and a resurrection at
|
||||
// once. Two devices that ended up with different host lists would be the worst possible outcome
|
||||
// for a synced vault, and no single-case test rules it out.
|
||||
var shared = await harness.First.CreateAsync(Host("a-shared"));
|
||||
var doomed = await harness.First.CreateAsync(Host("b-doomed"));
|
||||
await harness.SettleAsync();
|
||||
|
||||
await harness.First.UpdateAsync(shared, Host("a-shared", notes: "laptop note"));
|
||||
await harness.Second.UpdateAsync(shared, Host("a-shared", username: "desktop-user"));
|
||||
|
||||
await harness.First.DeleteAsync(doomed);
|
||||
await harness.Second.UpdateAsync(doomed, Host("b-doomed", notes: "still wanted"));
|
||||
|
||||
await harness.First.CreateAsync(Host("c-laptop-only"));
|
||||
await harness.Second.CreateAsync(Host("d-desktop-only"));
|
||||
|
||||
await harness.SettleAsync();
|
||||
await harness.SettleAsync();
|
||||
|
||||
var first = await harness.First.HostsSortedAsync();
|
||||
var second = await harness.Second.HostsSortedAsync();
|
||||
|
||||
first.ShouldBe(second);
|
||||
first.Count.ShouldBe(4);
|
||||
|
||||
first.Select(host => host.Label).ShouldBe(
|
||||
["a-shared", "b-doomed (restored)", "c-laptop-only", "d-desktop-only"]);
|
||||
|
||||
// The merged host kept both sides' contributions.
|
||||
var merged = first.Single(host => string.Equals(host.Label, "a-shared", StringComparison.Ordinal));
|
||||
merged.Notes.ShouldBe("laptop note");
|
||||
merged.Username.ShouldBe("desktop-user");
|
||||
|
||||
// And nothing is still queued anywhere.
|
||||
(await harness.First.Outbox.TakeAsync(VaultId, 100, TestContext.Current.CancellationToken))
|
||||
.ShouldBeEmpty();
|
||||
(await harness.Second.Outbox.TakeAsync(VaultId, 100, TestContext.Current.CancellationToken))
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private static Contracts.EncryptedPayload ForeignPayload() =>
|
||||
// Deliberately not decryptable: this stands in for another user's item, and the point of the test
|
||||
// is only that the change is not skipped. Pulling never decrypts, so nothing here needs to open.
|
||||
new([1, 2, 3], [4, 5], Guid.CreateVersion7(), 1, Crypto.CryptoSpec.CurrentAadVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a device's queued operation straight to the server, leaving the outbox row in place.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reproduces the one situation the engine cannot reach on its own: a push that the server applied
|
||||
/// and whose answer never came back. That window is where an idempotency key either saves the newer
|
||||
/// edit or destroys it.
|
||||
/// </remarks>
|
||||
private async Task PushBehindTheEnginesBackAsync(Guid entityId)
|
||||
{
|
||||
var pending = await harness.First.Outbox.FindAsync(
|
||||
VaultId, Contracts.SyncEntityType.Host, entityId, TestContext.Current.CancellationToken);
|
||||
|
||||
pending.ShouldNotBeNull();
|
||||
|
||||
await harness.Server.SyncPushAsync(
|
||||
VaultId,
|
||||
new Contracts.SyncPushRequest(
|
||||
[
|
||||
new Contracts.SyncPushOperation(
|
||||
pending.OperationId,
|
||||
pending.EntityType,
|
||||
pending.EntityId,
|
||||
pending.Operation,
|
||||
pending.ExpectedVersion,
|
||||
pending.Payload,
|
||||
pending.Fields),
|
||||
]),
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ConflictKind>> ConflictsAcrossDevicesAsync()
|
||||
{
|
||||
var first = await harness.First.ConflictsAsync();
|
||||
var second = await harness.Second.ConflictsAsync();
|
||||
|
||||
return [.. first.Concat(second).Select(conflict => conflict.Kind)];
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> DiscardedValuesAsync()
|
||||
{
|
||||
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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The conflict matrix, and the rest of the sync policy.
|
||||
|
||||
Driven through ISyncApi by an in-memory server that reproduces the real version checks, change-log
|
||||
sequences, tombstone rules and operation receipts. That is the point: a stubbed transport would only
|
||||
prove the right bytes were sent, whereas the question these tests exist to answer is what happens to
|
||||
a credential when two people edit one host at once.
|
||||
|
||||
Real SQLite caches and real DSH1 crypto on both sides — no fakes below this line — so a test that
|
||||
says nothing was lost is saying it about the code that will ship.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,377 @@
|
||||
using System.Globalization;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Sync.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// An in-memory vault server with the real sync semantics.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A faithful reimplementation of <c>DodoSSH.Api.Features.Sync.SyncService</c>'s decision table: the
|
||||
/// version check, the tombstone-beats-late-upsert rule, idempotent deletes, operation receipts, the
|
||||
/// change log, and cursors that are opaque to the client. It is not a stub that returns canned answers —
|
||||
/// if it were, none of the conflict tests would mean anything, because the interesting behaviour is
|
||||
/// exactly the server's refusal to apply a stale write.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The duplication against the real service is deliberate and is the point of the exercise: two
|
||||
/// independent expressions of the same rules, and <c>SyncEndpointTests</c> checks the other one against
|
||||
/// real Postgres. A shared implementation would let a misreading of the protocol pass on both sides.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class FakeVaultServer : ISyncApi
|
||||
{
|
||||
private readonly Dictionary<Guid, Row> rows = [];
|
||||
private readonly List<LogEntry> log = [];
|
||||
private readonly Dictionary<Guid, Receipt> receipts = [];
|
||||
|
||||
internal FakeVaultServer(Guid vaultId, uint keyGeneration = 1)
|
||||
{
|
||||
VaultId = vaultId;
|
||||
KeyGeneration = keyGeneration;
|
||||
}
|
||||
|
||||
internal Guid VaultId { get; }
|
||||
|
||||
internal uint KeyGeneration { get; set; }
|
||||
|
||||
/// <summary>The server's clock, so a test can create skew deliberately.</summary>
|
||||
internal DateTimeOffset Now { get; set; } = DateTimeOffset.FromUnixTimeSeconds(1_750_000_000);
|
||||
|
||||
/// <summary>Pull pages are capped here, as the real server clamps a client's requested limit.</summary>
|
||||
internal int MaxPullLimit { get; set; } = 500;
|
||||
|
||||
/// <summary>Forces the next push to answer <see cref="SyncOperationStatus.Forbidden"/>.</summary>
|
||||
internal bool DenyWrites { get; set; }
|
||||
|
||||
/// <summary>Pushes received, so a test can prove a retry did or did not happen.</summary>
|
||||
internal int PushCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Runs just before a push is applied, so a test can land another client's write in the window
|
||||
/// between one client's pull and its push. That window is the whole subject of the cursor-gap test.
|
||||
/// </summary>
|
||||
internal Action? OnPush { get; set; }
|
||||
|
||||
internal int RowCount => rows.Count(entry => !entry.Value.IsDeleted);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SyncPullResponse> SyncPullAsync(
|
||||
Guid vaultId,
|
||||
SyncPullRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var after = DecodeCursor(request.Cursor);
|
||||
var limit = Math.Clamp(request.Limit ?? MaxPullLimit, 1, MaxPullLimit);
|
||||
|
||||
var page = log.Where(entry => entry.Sequence > after).Take(limit + 1).ToList();
|
||||
|
||||
var hasMore = page.Count > limit;
|
||||
if (hasMore)
|
||||
{
|
||||
page.RemoveAt(page.Count - 1);
|
||||
}
|
||||
|
||||
// When nothing came back the cursor must not move, or a write landing between this read and the
|
||||
// next would be skipped for ever.
|
||||
var next = page.Count > 0 ? page[^1].Sequence : after;
|
||||
|
||||
return Task.FromResult(new SyncPullResponse(
|
||||
[.. page.Select(entry => Hydrate(entry))],
|
||||
EncodeCursor(next),
|
||||
hasMore,
|
||||
Now,
|
||||
KeyGeneration));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SyncPushResponse> SyncPushAsync(
|
||||
Guid vaultId,
|
||||
SyncPushRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PushCount++;
|
||||
|
||||
var interleaved = OnPush;
|
||||
OnPush = null;
|
||||
interleaved?.Invoke();
|
||||
|
||||
var results = new List<SyncPushResult>(request.Operations.Count);
|
||||
|
||||
foreach (var operation in request.Operations)
|
||||
{
|
||||
results.Add(Apply(operation));
|
||||
}
|
||||
|
||||
return Task.FromResult(new SyncPushResponse(results, EncodeCursor(Head)));
|
||||
}
|
||||
|
||||
/// <summary>Applies a change as if another client had made it.</summary>
|
||||
internal int ExternalUpsert(Guid entityId, EncryptedPayload payload, SyncPlaintextFields? fields)
|
||||
{
|
||||
var result = Apply(new SyncPushOperation(
|
||||
Guid.CreateVersion7(),
|
||||
SyncEntityType.Host,
|
||||
entityId,
|
||||
SyncOperation.Upsert,
|
||||
rows.TryGetValue(entityId, out var existing) && !existing.IsDeleted
|
||||
? existing.Version
|
||||
: null,
|
||||
payload,
|
||||
fields ?? new SyncPlaintextFields()));
|
||||
|
||||
if (result.Status != SyncOperationStatus.Applied)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The external write was not applied: {result.Status} — {result.Detail}.");
|
||||
}
|
||||
|
||||
return result.Version!.Value;
|
||||
}
|
||||
|
||||
/// <summary>Deletes as if another client had done it.</summary>
|
||||
internal void ExternalDelete(Guid entityId)
|
||||
{
|
||||
var existing = rows[entityId];
|
||||
|
||||
var result = Apply(new SyncPushOperation(
|
||||
Guid.CreateVersion7(),
|
||||
SyncEntityType.Host,
|
||||
entityId,
|
||||
SyncOperation.Delete,
|
||||
existing.Version,
|
||||
null,
|
||||
null));
|
||||
|
||||
if (result.Status != SyncOperationStatus.Applied)
|
||||
{
|
||||
throw new InvalidOperationException($"The external delete was not applied: {result.Status}.");
|
||||
}
|
||||
}
|
||||
|
||||
internal Row? Find(Guid entityId) => rows.TryGetValue(entityId, out var row) ? row : null;
|
||||
|
||||
private long Head => log.Count == 0 ? 0 : log[^1].Sequence;
|
||||
|
||||
// ---- The decision table ----
|
||||
|
||||
private SyncPushResult Apply(SyncPushOperation operation)
|
||||
{
|
||||
if (operation.EntityType != SyncEntityType.Host)
|
||||
{
|
||||
return Invalid(operation, $"Entity type {operation.EntityType} is not yet supported.");
|
||||
}
|
||||
|
||||
if (receipts.TryGetValue(operation.OperationId, out var receipt))
|
||||
{
|
||||
return new SyncPushResult(
|
||||
operation.OperationId,
|
||||
SyncOperationStatus.Duplicate,
|
||||
receipt.Version,
|
||||
receipt.Sequence,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
if (DenyWrites)
|
||||
{
|
||||
return new SyncPushResult(
|
||||
operation.OperationId, SyncOperationStatus.Forbidden, null, null, null, null);
|
||||
}
|
||||
|
||||
rows.TryGetValue(operation.EntityId, out var existing);
|
||||
|
||||
return operation.Operation == SyncOperation.Delete
|
||||
? ApplyDelete(operation, existing)
|
||||
: ApplyUpsert(operation, existing);
|
||||
}
|
||||
|
||||
private SyncPushResult ApplyUpsert(SyncPushOperation operation, Row? existing)
|
||||
{
|
||||
if (operation.Payload is null)
|
||||
{
|
||||
return Invalid(operation, "An upsert requires a payload.");
|
||||
}
|
||||
|
||||
if (operation.Payload.WrappedDataKey.Length == 0 || operation.Payload.DataKeyId == Guid.Empty)
|
||||
{
|
||||
return Invalid(operation, "A payload requires its data key.");
|
||||
}
|
||||
|
||||
var fields = operation.PlaintextFields ?? new SyncPlaintextFields();
|
||||
|
||||
if (!fields.RelayEnabled && (fields.Hostname is not null || fields.Port is not null))
|
||||
{
|
||||
return Invalid(operation, "An address may only be supplied when relay is enabled.");
|
||||
}
|
||||
|
||||
if (fields.RelayEnabled && (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null))
|
||||
{
|
||||
return Invalid(operation, "Relay-enabled hosts require both a hostname and a port.");
|
||||
}
|
||||
|
||||
if (existing is null || existing.IsDeleted)
|
||||
{
|
||||
return Create(operation, existing, fields);
|
||||
}
|
||||
|
||||
if (operation.ExpectedVersion != existing.Version)
|
||||
{
|
||||
return Conflict(operation, existing);
|
||||
}
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Version = existing.Version + 1,
|
||||
Payload = operation.Payload,
|
||||
Fields = fields,
|
||||
IsDeleted = false,
|
||||
};
|
||||
|
||||
return Commit(operation, updated, SyncOperation.Upsert);
|
||||
}
|
||||
|
||||
private SyncPushResult Create(SyncPushOperation operation, Row? existing, SyncPlaintextFields fields)
|
||||
{
|
||||
// A tombstone beats a late upsert. The client is told so it can resurrect the item deliberately
|
||||
// under a new id rather than silently undoing someone else's delete.
|
||||
if (existing?.IsDeleted == true)
|
||||
{
|
||||
return Conflict(operation, existing);
|
||||
}
|
||||
|
||||
if (operation.ExpectedVersion is not null)
|
||||
{
|
||||
// The client believes it is updating something that does not exist here.
|
||||
return Conflict(operation, existing: null);
|
||||
}
|
||||
|
||||
var created = new Row(operation.EntityId, 1, 0, operation.Payload!, fields, false);
|
||||
return Commit(operation, created, SyncOperation.Upsert);
|
||||
}
|
||||
|
||||
private SyncPushResult ApplyDelete(SyncPushOperation operation, Row? existing)
|
||||
{
|
||||
if (existing is null)
|
||||
{
|
||||
return Invalid(operation, "Cannot delete an item that does not exist.");
|
||||
}
|
||||
|
||||
if (existing.IsDeleted)
|
||||
{
|
||||
// Idempotent: a client retrying a delete it is unsure about should not have to tell these
|
||||
// two situations apart.
|
||||
return new SyncPushResult(
|
||||
operation.OperationId,
|
||||
SyncOperationStatus.Applied,
|
||||
existing.Version,
|
||||
existing.ChangeSequence,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
if (operation.ExpectedVersion is not null && operation.ExpectedVersion != existing.Version)
|
||||
{
|
||||
return Conflict(operation, existing);
|
||||
}
|
||||
|
||||
var tombstone = existing with
|
||||
{
|
||||
Version = existing.Version + 1,
|
||||
IsDeleted = true,
|
||||
// The address goes with the item, or the server stays able to resolve a host the user
|
||||
// believes they deleted.
|
||||
Fields = new SyncPlaintextFields(),
|
||||
};
|
||||
|
||||
return Commit(operation, tombstone, SyncOperation.Delete);
|
||||
}
|
||||
|
||||
private SyncPushResult Commit(SyncPushOperation operation, Row row, SyncOperation change)
|
||||
{
|
||||
var sequence = Head + 1;
|
||||
|
||||
log.Add(new LogEntry(sequence, row.EntityId, change, row.Version, Now));
|
||||
rows[row.EntityId] = row with { ChangeSequence = sequence };
|
||||
receipts[operation.OperationId] = new Receipt(row.Version, sequence);
|
||||
|
||||
return new SyncPushResult(
|
||||
operation.OperationId, SyncOperationStatus.Applied, row.Version, sequence, null, null);
|
||||
}
|
||||
|
||||
private SyncPushResult Conflict(SyncPushOperation operation, Row? existing) =>
|
||||
new(
|
||||
operation.OperationId,
|
||||
SyncOperationStatus.Conflict,
|
||||
existing?.Version,
|
||||
existing?.ChangeSequence,
|
||||
existing is null ? null : ToChange(existing),
|
||||
null);
|
||||
|
||||
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
|
||||
new(operation.OperationId, SyncOperationStatus.Invalid, null, null, null, detail);
|
||||
|
||||
private SyncChange Hydrate(LogEntry entry)
|
||||
{
|
||||
var row = rows[entry.EntityId];
|
||||
return ToChange(row, entry.Sequence, entry.Revision, entry.OccurredAt);
|
||||
}
|
||||
|
||||
private SyncChange ToChange(Row row, long? sequence = null, int? version = null, DateTimeOffset? at = null) =>
|
||||
new(
|
||||
SyncEntityType.Host,
|
||||
row.EntityId,
|
||||
row.IsDeleted ? SyncOperation.Delete : SyncOperation.Upsert,
|
||||
version ?? row.Version,
|
||||
sequence ?? row.ChangeSequence,
|
||||
// A delete carries no payload: there is nothing left to decrypt, and shipping the pre-delete
|
||||
// ciphertext would undermine the point of the tombstone.
|
||||
row.IsDeleted ? null : row.Payload,
|
||||
row.IsDeleted ? null : row.Fields,
|
||||
at ?? Now);
|
||||
|
||||
// ---- Cursors ----
|
||||
|
||||
/// <remarks>
|
||||
/// Prefixed and non-numeric so a client that tried to compute one would produce something this
|
||||
/// rejects. The real server HMAC-tags them; the property that matters to the client is only that it
|
||||
/// must round-trip what it is given.
|
||||
/// </remarks>
|
||||
private static string EncodeCursor(long sequence) =>
|
||||
"fake-v1:" + sequence.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
private static long DecodeCursor(string? cursor)
|
||||
{
|
||||
if (string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!cursor.StartsWith("fake-v1:", StringComparison.Ordinal)
|
||||
|| !long.TryParse(cursor.AsSpan(8), CultureInfo.InvariantCulture, out var sequence))
|
||||
{
|
||||
throw new InvalidOperationException($"A client sent a cursor it should not have: '{cursor}'.");
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
internal sealed record Row(
|
||||
Guid EntityId,
|
||||
int Version,
|
||||
long ChangeSequence,
|
||||
EncryptedPayload Payload,
|
||||
SyncPlaintextFields Fields,
|
||||
bool IsDeleted);
|
||||
|
||||
private sealed record LogEntry(
|
||||
long Sequence,
|
||||
Guid EntityId,
|
||||
SyncOperation Operation,
|
||||
int Revision,
|
||||
DateTimeOffset OccurredAt);
|
||||
|
||||
private sealed record Receipt(int Version, long Sequence);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Sync.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Sealing and opening a host payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mostly negative tests, and deliberately so. docs/crypto.md §4.4 claims a server holding every
|
||||
/// ciphertext still cannot move a payload between rows, roll one back to an earlier generation, or pair
|
||||
/// one item's envelope with another's key wrap. Those claims are only worth making if something checks
|
||||
/// them at the layer that actually assembles the AAD.
|
||||
/// </remarks>
|
||||
public sealed class HostCipherTests
|
||||
{
|
||||
private static readonly Guid HostA = Guid.Parse("0192f0c8-000a-7c3d-8e4f-5a6b7c8d9e0f");
|
||||
private static readonly Guid HostB = Guid.Parse("0192f0c8-000b-7c3d-8e4f-5a6b7c8d9e0f");
|
||||
|
||||
private readonly byte[] vaultKey = VaultKeys.Create();
|
||||
private readonly byte[] otherVaultKey = VaultKeys.Create();
|
||||
|
||||
[Fact]
|
||||
public void AHost_RoundTrips()
|
||||
{
|
||||
var host = Host();
|
||||
var payload = HostCipher.Seal(host, vaultKey, HostA, keyGeneration: 1, itemVersion: 1);
|
||||
|
||||
var opened = HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 1);
|
||||
|
||||
opened.ShouldNotBeNull();
|
||||
opened.Host.ShouldBe(host);
|
||||
opened.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EverySeal_UsesAFreshDataKey()
|
||||
{
|
||||
// One key per item version, so nonce-collision analysis is moot and a rotation re-wraps 32 bytes
|
||||
// rather than rewriting content.
|
||||
var host = Host();
|
||||
|
||||
var first = HostCipher.Seal(host, vaultKey, HostA, 1, 1);
|
||||
var second = HostCipher.Seal(host, vaultKey, HostA, 1, 1);
|
||||
|
||||
first.DataKeyId.ShouldNotBe(second.DataKeyId);
|
||||
first.WrappedDataKey.ShouldNotBe(second.WrappedDataKey);
|
||||
first.Envelope.ShouldNotBe(second.Envelope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APayload_CannotBeReadAsAnotherItem()
|
||||
{
|
||||
// The property that stops a server pasting one host's payload onto another row.
|
||||
var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);
|
||||
|
||||
HostCipher.TryOpen(payload, vaultKey, HostB, itemVersion: 1).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APayload_CannotBeReadAtAnotherVersion()
|
||||
{
|
||||
// The sharpest edge in this layer. A payload is sealed at the version the server will assign, so
|
||||
// getting that prediction wrong produces something that encrypts cleanly and never decrypts. The
|
||||
// binding is what turns a silent corruption into a visible failure.
|
||||
var payload = HostCipher.Seal(Host(), vaultKey, HostA, keyGeneration: 1, itemVersion: 2);
|
||||
|
||||
HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 1).ShouldBeNull();
|
||||
HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 3).ShouldBeNull();
|
||||
HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 2).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APayload_CannotBeRolledBackToAnEarlierKeyGeneration()
|
||||
{
|
||||
var payload = HostCipher.Seal(Host(), vaultKey, HostA, keyGeneration: 2, itemVersion: 1);
|
||||
|
||||
// The generation travels with the payload, so a server rewriting the column to 1 changes the AAD
|
||||
// the client recomputes and the tag fails.
|
||||
var rolledBack = payload with { KeyGeneration = 1 };
|
||||
|
||||
HostCipher.TryOpen(rolledBack, vaultKey, HostA, itemVersion: 1).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APayload_CannotBeReadWithAnotherVaultsKey()
|
||||
{
|
||||
var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);
|
||||
|
||||
HostCipher.TryOpen(payload, otherVaultKey, HostA, itemVersion: 1).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneItemsEnvelope_CannotBePairedWithAnothersKeyWrap()
|
||||
{
|
||||
// What content_key_id is in the AAD for. Without it the two halves of a payload would be
|
||||
// interchangeable and a server could mix them.
|
||||
var first = HostCipher.Seal(Host(label: "one"), vaultKey, HostA, 1, 1);
|
||||
var second = HostCipher.Seal(Host(label: "two"), vaultKey, HostA, 1, 1);
|
||||
|
||||
var mixed = first with { WrappedDataKey = second.WrappedDataKey };
|
||||
|
||||
HostCipher.TryOpen(mixed, vaultKey, HostA, itemVersion: 1).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ATamperedEnvelope_DoesNotOpen()
|
||||
{
|
||||
var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);
|
||||
|
||||
var tampered = payload.Envelope.ToArray();
|
||||
tampered[^1] ^= 0xFF;
|
||||
|
||||
HostCipher.TryOpen(payload with { Envelope = tampered }, vaultKey, HostA, 1).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASubstitutedDataKeyId_DoesNotOpen()
|
||||
{
|
||||
var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);
|
||||
|
||||
HostCipher.TryOpen(payload with { DataKeyId = Guid.CreateVersion7() }, vaultKey, HostA, 1)
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AMissingDataKey_IsRefusedRatherThanThrowing()
|
||||
{
|
||||
// What a row written before the data key existed in the contract would look like. It must degrade
|
||||
// to one unreadable item, not to an exception inside a sync pass.
|
||||
var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);
|
||||
|
||||
HostCipher.TryOpen(payload with { WrappedDataKey = [] }, vaultKey, HostA, 1).ShouldBeNull();
|
||||
HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 0).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seal_RefusesAVersionBelowOne()
|
||||
{
|
||||
// Versions start at 1, and a zero would silently produce a payload no push could ever match.
|
||||
Should.Throw<ArgumentOutOfRangeException>(
|
||||
() => HostCipher.Seal(Host(), vaultKey, HostA, 1, itemVersion: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seal_RefusesAHostThatCannotBeStored()
|
||||
{
|
||||
Should.Throw<ArgumentException>(
|
||||
() => HostCipher.Seal(Host(label: " "), vaultKey, HostA, 1, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheNextVersion_IsOneMoreThanTheVersionBeingReplaced()
|
||||
{
|
||||
// The prediction both the sealing and the opening side depend on. If these two ever disagreed the
|
||||
// result would be an item that encrypts and never decrypts, so they share one definition.
|
||||
SyncVersions.NextVersion(null).ShouldBe(1);
|
||||
SyncVersions.NextVersion(1).ShouldBe(2);
|
||||
SyncVersions.NextVersion(41).ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARelayEnabledHost_ExposesItsAddressAndNothingElseDoes()
|
||||
{
|
||||
// The single point at which a hostname can leave the payload. With relay off the server learns
|
||||
// only that an item exists; see ADR 0004.
|
||||
var off = HostFields.From(Host(relayEnabled: false));
|
||||
off.RelayEnabled.ShouldBeFalse();
|
||||
off.Hostname.ShouldBeNull();
|
||||
off.Port.ShouldBeNull();
|
||||
|
||||
var on = HostFields.From(Host(hostname: "bastion.internal", port: 2222, relayEnabled: true));
|
||||
on.RelayEnabled.ShouldBeTrue();
|
||||
on.Hostname.ShouldBe("bastion.internal");
|
||||
on.Port.ShouldBe(2222);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheRelayFlagIsInsideThePayload_SoItSurvivesARoundTrip()
|
||||
{
|
||||
// It has to be, or two clients could silently disagree about it and one would re-expose an
|
||||
// address the other had just withdrawn.
|
||||
var host = Host(relayEnabled: true);
|
||||
var payload = HostCipher.Seal(host, vaultKey, HostA, 1, 1);
|
||||
|
||||
HostCipher.TryOpen(payload, vaultKey, HostA, 1)!.Host.RelayEnabled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static HostSecret Host(
|
||||
string label = "prod-db",
|
||||
string hostname = "db.internal",
|
||||
int port = 22,
|
||||
bool relayEnabled = false) =>
|
||||
new()
|
||||
{
|
||||
Label = label,
|
||||
Hostname = hostname,
|
||||
Port = port,
|
||||
Username = "deploy",
|
||||
Options = HostOptions.Create([new HostOption("Compression", "yes")]),
|
||||
RelayEnabled = relayEnabled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace DodoSSH.Client.Sync.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The id a rescued item takes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Determinism here is what makes the rescue crash-safe. The reconciler queues the restored copy before
|
||||
/// it clears the original, and those are two separate transactions — so a process that dies between them
|
||||
/// leaves the original pending and resurrects again on the next pass. Landing on the same id means that
|
||||
/// second attempt coalesces into the row already queued instead of leaving the user with duplicates.
|
||||
/// </remarks>
|
||||
public sealed class ResurrectionIdTests
|
||||
{
|
||||
private static readonly Guid Original = Guid.Parse("0192f0c8-1234-7c3d-8e4f-5a6b7c8d9e0f");
|
||||
|
||||
private static readonly Guid Other = Guid.Parse("0192f0c8-5678-7c3d-8e4f-5a6b7c8d9e0f");
|
||||
|
||||
[Fact]
|
||||
public void TheSameTombstone_AlwaysYieldsTheSameId()
|
||||
{
|
||||
ResurrectionId.For(Original, 3).ShouldBe(ResurrectionId.For(Original, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADifferentItem_YieldsADifferentId()
|
||||
{
|
||||
ResurrectionId.For(Original, 3).ShouldNotBe(ResurrectionId.For(Other, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADifferentTombstoneVersion_YieldsADifferentId()
|
||||
{
|
||||
// An item deleted, restored, and deleted again must produce a second rescue rather than
|
||||
// colliding with the first.
|
||||
ResurrectionId.For(Original, 3).ShouldNotBe(ResurrectionId.For(Original, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheIdIsNotTheOriginal()
|
||||
{
|
||||
// The tombstone stands, so the rescued copy has to be a different item. Reusing the id would
|
||||
// conflict against the tombstone for ever.
|
||||
ResurrectionId.For(Original, 1).ShouldNotBe(Original);
|
||||
ResurrectionId.For(Original, 1).ShouldNotBe(Guid.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using static DodoSSH.Client.Sync.Tests.SyncHarness;
|
||||
|
||||
namespace DodoSSH.Client.Sync.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The mechanics of a pass: paging, batching, bounds, and what the cursor is allowed to be.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Separate from the conflict matrix because the failure modes are different. Here a mistake shows up as
|
||||
/// a sync that never finishes, or one that quietly stops halfway and reports success.
|
||||
/// </remarks>
|
||||
public sealed class SyncEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task APullLargerThanOnePage_ReadsEveryChange()
|
||||
{
|
||||
// The server clamps a client's requested limit, so a client that trusted one response to be the
|
||||
// whole story would silently see part of a vault.
|
||||
using var harness = await CreateAsync(
|
||||
new SyncOptions { PullPageSize = 2, MaxOperationsPerPush = 100 });
|
||||
|
||||
harness.Server.MaxPullLimit = 2;
|
||||
|
||||
for (var index = 0; index < 7; index++)
|
||||
{
|
||||
await harness.First.CreateAsync(Host($"host-{index}"));
|
||||
}
|
||||
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
var report = await harness.Second.SyncAsync();
|
||||
|
||||
report.Pulled.ShouldBe(7);
|
||||
(await harness.Second.ListAsync()).Hosts.Count.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoreQueuedChangesThanOneBatch_AreAllPushed()
|
||||
{
|
||||
using var harness = await CreateAsync(new SyncOptions { MaxOperationsPerPush = 2 });
|
||||
|
||||
for (var index = 0; index < 5; index++)
|
||||
{
|
||||
await harness.First.CreateAsync(Host($"host-{index}"));
|
||||
}
|
||||
|
||||
var report = await harness.First.SyncAsync();
|
||||
|
||||
report.Pushed.ShouldBe(5);
|
||||
harness.Server.RowCount.ShouldBe(5);
|
||||
|
||||
// Three rounds of two, so the drain loop genuinely continued rather than stopping at one batch.
|
||||
harness.Server.PushCount.ShouldBeGreaterThanOrEqualTo(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnExhaustedPushLoop_SaysSoRatherThanPretendingItFinished()
|
||||
{
|
||||
// A bound is necessary — each round advances, but against a vault someone else writes to
|
||||
// continuously a pass could keep finding work. Reporting it is what stops that looking like
|
||||
// success.
|
||||
using var harness = await CreateAsync(
|
||||
new SyncOptions { MaxOperationsPerPush = 1, MaxPushRounds = 2 });
|
||||
|
||||
for (var index = 0; index < 5; index++)
|
||||
{
|
||||
await harness.First.CreateAsync(Host($"host-{index}"));
|
||||
}
|
||||
|
||||
var report = await harness.First.SyncAsync();
|
||||
|
||||
report.RoundsExhausted.ShouldBeTrue();
|
||||
report.Pushed.ShouldBe(2);
|
||||
|
||||
// And the rest is still queued, not lost.
|
||||
(await harness.First.Outbox.TakeAsync(VaultId, 100, TestContext.Current.CancellationToken))
|
||||
.Count.ShouldBe(3);
|
||||
|
||||
// A further pass picks up where this one stopped.
|
||||
await harness.First.SyncAsync();
|
||||
await harness.First.SyncAsync();
|
||||
harness.Server.RowCount.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheCursor_IsWhateverTheServerIssued()
|
||||
{
|
||||
// Opaque and integrity-tagged. The fake server rejects a cursor it did not mint, so a client that
|
||||
// computed one would fail here rather than quietly resuming from a position it invented.
|
||||
using var harness = await CreateAsync();
|
||||
|
||||
await harness.First.CreateAsync(Host("prod-db"));
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
var state = await harness.First.SyncState.ReadAsync(
|
||||
VaultId, TestContext.Current.CancellationToken);
|
||||
|
||||
state.Cursor.ShouldNotBeNull();
|
||||
state.Cursor.ShouldStartWith("fake-v1:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnEmptyPull_DoesNotMoveTheCursor()
|
||||
{
|
||||
// If it did, a write landing between this read and the next would be skipped for ever.
|
||||
using var harness = await CreateAsync();
|
||||
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
var before = await harness.First.SyncState.ReadAsync(
|
||||
VaultId, TestContext.Current.CancellationToken);
|
||||
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
var after = await harness.First.SyncState.ReadAsync(
|
||||
VaultId, TestContext.Current.CancellationToken);
|
||||
|
||||
after.Cursor.ShouldBe(before.Cursor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClockSkew_IsRecordedAndNotActedOn()
|
||||
{
|
||||
// Recorded because a user should be able to see it. Not acted on because the merge decides by
|
||||
// version and retained ancestor — a skewed clock must not be able to pick a winner.
|
||||
using var harness = await CreateAsync();
|
||||
|
||||
harness.Server.Now = TimeProvider.System.GetUtcNow().AddHours(3);
|
||||
|
||||
var entityId = await harness.First.CreateAsync(Host("prod-db"));
|
||||
var report = await harness.First.SyncAsync();
|
||||
|
||||
report.ServerTimeSkewMs.ShouldBeGreaterThan(2 * 60 * 60 * 1000);
|
||||
|
||||
// The item still round-trips, so nothing downstream depended on the timestamp.
|
||||
(await harness.First.FindAsync(entityId)).Host.Label.ShouldBe("prod-db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASyncWithNothingToDo_TouchesTheServerOnceAndReportsNothing()
|
||||
{
|
||||
using var harness = await CreateAsync();
|
||||
|
||||
var report = await harness.First.SyncAsync();
|
||||
|
||||
report.Pulled.ShouldBe(0);
|
||||
report.Pushed.ShouldBe(0);
|
||||
report.NeedsAttention.ShouldBeFalse();
|
||||
harness.Server.PushCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AVaultWithNoUsableGrant_IsReportedRatherThanRead()
|
||||
{
|
||||
// A grant awaiting re-wrap after a rekey. The vault is temporarily unreadable and saying so is
|
||||
// the only honest answer — showing an empty host list would be indistinguishable from an empty
|
||||
// vault.
|
||||
using var harness = await CreateAsync();
|
||||
|
||||
var unknown = Guid.CreateVersion7();
|
||||
|
||||
harness.First.Keyring.CanRead(unknown).ShouldBeFalse();
|
||||
|
||||
await Should.ThrowAsync<VaultUnreadableException>(
|
||||
async () => await harness.First.Hosts.ListAsync(
|
||||
unknown, TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Sync.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// One machine: its own cache, its own outbox, its own view of the vault.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A separate SQLite database per device, because the whole subject of these tests is two caches
|
||||
/// diverging and being reconciled. Sharing one would make every conflict test vacuous.
|
||||
/// </remarks>
|
||||
internal sealed class SyncDevice : IDisposable
|
||||
{
|
||||
private readonly ClientCacheFactory factory;
|
||||
private readonly MasterKey master;
|
||||
private readonly LocalCacheProtector protector;
|
||||
|
||||
private SyncDevice(
|
||||
string name,
|
||||
ClientCacheFactory factory,
|
||||
MasterKey master,
|
||||
LocalCacheProtector protector,
|
||||
VaultKeyring keyring,
|
||||
FakeVaultServer server,
|
||||
SyncOptions options)
|
||||
{
|
||||
Name = name;
|
||||
this.factory = factory;
|
||||
this.master = master;
|
||||
this.protector = protector;
|
||||
Keyring = keyring;
|
||||
|
||||
Items = new ItemStore(factory, protector);
|
||||
Outbox = new OutboxStore(factory, protector, TimeProvider.System);
|
||||
SyncState = new SyncStateStore(factory);
|
||||
Conflicts = new ConflictStore(factory, protector, TimeProvider.System);
|
||||
Hosts = new HostRepository(Items, Outbox, keyring);
|
||||
|
||||
Engine = new SyncEngine(
|
||||
server, Items, Outbox, SyncState, Conflicts, keyring, TimeProvider.System, options);
|
||||
}
|
||||
|
||||
internal string Name { get; }
|
||||
|
||||
internal VaultKeyring Keyring { get; }
|
||||
|
||||
internal ItemStore Items { get; }
|
||||
|
||||
internal OutboxStore Outbox { get; }
|
||||
|
||||
internal SyncStateStore SyncState { get; }
|
||||
|
||||
internal ConflictStore Conflicts { get; }
|
||||
|
||||
internal HostRepository Hosts { get; }
|
||||
|
||||
internal SyncEngine Engine { get; }
|
||||
|
||||
internal static async Task<SyncDevice> CreateAsync(
|
||||
string name,
|
||||
UserSecretBundle bundle,
|
||||
StoredVault vault,
|
||||
FakeVaultServer server,
|
||||
SyncOptions options)
|
||||
{
|
||||
var cache = ClientCacheFactory.ForMemory($"sync-{name}-{Guid.CreateVersion7():N}");
|
||||
|
||||
try
|
||||
{
|
||||
await cache.MigrateAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var derived = MasterKey.Derive(
|
||||
$"passphrase-{name}", new byte[CryptoSpec.SaltSize], SyncHarness.CheapProfile);
|
||||
|
||||
// Opened through the real grant, so the keyring, the wrap and the AAD are all exercised.
|
||||
var keyring = VaultKeyring.Open(bundle, [vault]);
|
||||
|
||||
return new SyncDevice(
|
||||
name, cache, derived, LocalCacheProtector.From(derived), keyring, server, options);
|
||||
}
|
||||
catch
|
||||
{
|
||||
cache.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
internal Task<SyncReport> SyncAsync() =>
|
||||
Engine.SyncAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
|
||||
|
||||
internal Task<HostListing> ListAsync() =>
|
||||
Hosts.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
|
||||
|
||||
internal async Task<IReadOnlyList<HostSecret>> HostsSortedAsync()
|
||||
{
|
||||
var listing = await ListAsync();
|
||||
|
||||
return [.. listing.Hosts.Select(h => h.Host).OrderBy(h => h.Label, StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
internal async Task<VaultHost> FindAsync(Guid entityId)
|
||||
{
|
||||
var listing = await ListAsync();
|
||||
|
||||
return listing.Hosts.SingleOrDefault(host => host.EntityId == entityId)
|
||||
?? throw new InvalidOperationException($"{Name} cannot see host {entityId}.");
|
||||
}
|
||||
|
||||
internal Task<Guid> CreateAsync(HostSecret host) =>
|
||||
Hosts.CreateAsync(SyncHarness.VaultId, host, TestContext.Current.CancellationToken);
|
||||
|
||||
internal Task UpdateAsync(Guid entityId, HostSecret host) =>
|
||||
Hosts.UpdateAsync(SyncHarness.VaultId, entityId, host, TestContext.Current.CancellationToken);
|
||||
|
||||
internal Task DeleteAsync(Guid entityId) =>
|
||||
Hosts.DeleteAsync(SyncHarness.VaultId, entityId, TestContext.Current.CancellationToken);
|
||||
|
||||
internal Task<IReadOnlyList<StoredConflict>> ConflictsAsync() =>
|
||||
Conflicts.ListAsync(SyncHarness.VaultId, false, TestContext.Current.CancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Keyring.Dispose();
|
||||
protector.Dispose();
|
||||
master.Dispose();
|
||||
factory.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One user, one vault, two machines and a server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both devices share the identity bundle, which is what a single user on a laptop and a desktop
|
||||
/// actually looks like: one enrolled key pair, one vault grant, two independent local caches. That is
|
||||
/// also the cheapest realistic setup in which every conflict case can be produced.
|
||||
/// </remarks>
|
||||
internal sealed class SyncHarness : IDisposable
|
||||
{
|
||||
internal static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private readonly UserSecretBundle bundle;
|
||||
|
||||
private SyncHarness(UserSecretBundle bundle, FakeVaultServer server, SyncDevice first, SyncDevice second)
|
||||
{
|
||||
this.bundle = bundle;
|
||||
Server = server;
|
||||
First = first;
|
||||
Second = second;
|
||||
}
|
||||
|
||||
internal static Guid VaultId { get; } = Guid.Parse("0192f0c8-7777-7c3d-8e4f-5a6b7c8d9e0f");
|
||||
|
||||
internal FakeVaultServer Server { get; }
|
||||
|
||||
/// <summary>The laptop.</summary>
|
||||
internal SyncDevice First { get; }
|
||||
|
||||
/// <summary>The desktop.</summary>
|
||||
internal SyncDevice Second { get; }
|
||||
|
||||
internal static async Task<SyncHarness> CreateAsync(SyncOptions? options = null)
|
||||
{
|
||||
var effective = options ?? SyncOptions.Default;
|
||||
|
||||
var identity = UserSecretBundle.Create(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000));
|
||||
|
||||
try
|
||||
{
|
||||
var vaultKey = VaultKeys.Create();
|
||||
var wrapped = VaultKeys.WrapTo(vaultKey, identity.EncryptionPublicKey, VaultId, 1);
|
||||
|
||||
// The plaintext key is not retained: each device unwraps the grant itself, as it would after
|
||||
// an ordinary unlock.
|
||||
System.Security.Cryptography.CryptographicOperations.ZeroMemory(vaultKey);
|
||||
|
||||
var vault = new StoredVault(
|
||||
VaultId, "Personal", IsPersonal: true, TeamId: null, KeyGeneration: 1,
|
||||
Permissions: 31, wrapped, RekeyRequired: false);
|
||||
|
||||
var server = new FakeVaultServer(VaultId);
|
||||
|
||||
var first = await SyncDevice.CreateAsync("laptop", identity, vault, server, effective);
|
||||
|
||||
try
|
||||
{
|
||||
var second = await SyncDevice.CreateAsync("desktop", identity, vault, server, effective);
|
||||
return new SyncHarness(identity, server, first, second);
|
||||
}
|
||||
catch
|
||||
{
|
||||
first.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
identity.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Brings both devices up to date, twice, so the result is a settled state.</summary>
|
||||
/// <remarks>
|
||||
/// Twice because one pass per device is not enough for a change made on one to be merged on the
|
||||
/// other and then pushed back. Asserting on a settled state rather than on an intermediate one is
|
||||
/// what makes "the two devices converge" a meaningful claim.
|
||||
/// </remarks>
|
||||
internal async Task SettleAsync()
|
||||
{
|
||||
for (var round = 0; round < 2; round++)
|
||||
{
|
||||
await First.SyncAsync();
|
||||
await Second.SyncAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
First.Dispose();
|
||||
Second.Dispose();
|
||||
bundle.Dispose();
|
||||
}
|
||||
|
||||
// ---- Builders ----
|
||||
|
||||
internal static HostSecret Host(
|
||||
string label,
|
||||
string hostname = "db.internal",
|
||||
int port = 22,
|
||||
string? username = "deploy",
|
||||
string? notes = null,
|
||||
(string Name, string Value)[]? options = null,
|
||||
bool relayEnabled = false) =>
|
||||
new()
|
||||
{
|
||||
Label = label,
|
||||
Hostname = hostname,
|
||||
Port = port,
|
||||
Username = username,
|
||||
Notes = notes,
|
||||
Options = options is null
|
||||
? HostOptions.Empty
|
||||
: HostOptions.Create(options.Select(o => new HostOption(o.Name, o.Value))),
|
||||
RelayEnabled = relayEnabled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.134, )",
|
||||
"resolved": "3.0.134",
|
||||
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
|
||||
},
|
||||
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.0, )",
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==",
|
||||
"dependencies": {
|
||||
"Castle.Core": "5.1.1"
|
||||
}
|
||||
},
|
||||
"Shouldly": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.3.0, )",
|
||||
"resolved": "4.3.0",
|
||||
"contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==",
|
||||
"dependencies": {
|
||||
"DiffEngine": "11.3.0",
|
||||
"EmptyFiles": "4.4.0"
|
||||
}
|
||||
},
|
||||
"xunit.v3": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.2.2, )",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
|
||||
"dependencies": {
|
||||
"xunit.v3.mtp-v1": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"Castle.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.1.1",
|
||||
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
|
||||
"dependencies": {
|
||||
"System.Diagnostics.EventLog": "6.0.0"
|
||||
}
|
||||
},
|
||||
"DiffEngine": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.0",
|
||||
"contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==",
|
||||
"dependencies": {
|
||||
"EmptyFiles": "4.4.0",
|
||||
"System.Management": "6.0.1"
|
||||
}
|
||||
},
|
||||
"EmptyFiles": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.4.0",
|
||||
"contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw=="
|
||||
},
|
||||
"Microsoft.ApplicationInsights": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
|
||||
},
|
||||
"Microsoft.Bcl.AsyncInterfaces": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
|
||||
},
|
||||
"Microsoft.Data.Sqlite.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA=="
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q=="
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Sqlite.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "YbVWMIouzwTKBiLms8boa7xeRT88wI14R1msv3XExFk9n0/sa8nU7MwDa1CKtfLGMJs7O7QWuS9/xhcQ72AD2A==",
|
||||
"dependencies": {
|
||||
"Microsoft.Data.Sqlite.Core": "10.0.10",
|
||||
"Microsoft.EntityFrameworkCore.Relational": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.DependencyModel": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10",
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Caching.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Caching.Memory": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Caching.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Options": "10.0.10",
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA=="
|
||||
},
|
||||
"Microsoft.Extensions.Logging": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "10.0.10",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Options": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Options": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Primitives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ=="
|
||||
},
|
||||
"Microsoft.Testing.Extensions.Telemetry": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.ApplicationInsights": "2.23.0",
|
||||
"Microsoft.Testing.Platform": "1.9.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.Testing.Extensions.TrxReport.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Platform": "1.9.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.Testing.Platform": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
|
||||
},
|
||||
"Microsoft.Testing.Platform.MSBuild": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.9.1",
|
||||
"contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Platform": "1.9.1"
|
||||
}
|
||||
},
|
||||
"Microsoft.Win32.Registry": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.0.0",
|
||||
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
|
||||
},
|
||||
"System.CodeDom": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
|
||||
},
|
||||
"System.Diagnostics.EventLog": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw=="
|
||||
},
|
||||
"System.Management": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.1",
|
||||
"contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==",
|
||||
"dependencies": {
|
||||
"System.CodeDom": "6.0.0"
|
||||
}
|
||||
},
|
||||
"xunit.analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.27.0",
|
||||
"contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
|
||||
},
|
||||
"xunit.v3.assert": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
|
||||
},
|
||||
"xunit.v3.common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Bcl.AsyncInterfaces": "6.0.0"
|
||||
}
|
||||
},
|
||||
"xunit.v3.core.mtp-v1": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Extensions.Telemetry": "1.9.1",
|
||||
"Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
|
||||
"Microsoft.Testing.Platform": "1.9.1",
|
||||
"Microsoft.Testing.Platform.MSBuild": "1.9.1",
|
||||
"xunit.v3.extensibility.core": "[3.2.2]",
|
||||
"xunit.v3.runner.inproc.console": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.extensibility.core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
|
||||
"dependencies": {
|
||||
"xunit.v3.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.mtp-v1": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
|
||||
"dependencies": {
|
||||
"xunit.analyzers": "1.27.0",
|
||||
"xunit.v3.assert": "[3.2.2]",
|
||||
"xunit.v3.core.mtp-v1": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.runner.common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
|
||||
"dependencies": {
|
||||
"Microsoft.Win32.Registry": "[5.0.0]",
|
||||
"xunit.v3.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"xunit.v3.runner.inproc.console": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.2",
|
||||
"contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
|
||||
"dependencies": {
|
||||
"xunit.v3.extensibility.core": "[3.2.2]",
|
||||
"xunit.v3.runner.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"dodossh.client.api": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Auth": "[1.0.0, )",
|
||||
"DodoSSH.Contracts": "[1.0.0, )",
|
||||
"DodoSSH.Crypto": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.auth": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Contracts": "[1.0.0, )",
|
||||
"DodoSSH.Crypto": "[1.0.0, )",
|
||||
"EFCore.NamingConventions": "[10.0.1, )",
|
||||
"Microsoft.EntityFrameworkCore.Sqlite": "[10.0.10, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.sync": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Api": "[1.0.0, )",
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Storage": "[1.0.0, )",
|
||||
"DodoSSH.Contracts": "[1.0.0, )",
|
||||
"DodoSSH.Crypto": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.contracts": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )"
|
||||
}
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.1, )",
|
||||
"resolved": "10.0.1",
|
||||
"contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)",
|
||||
"Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1"
|
||||
}
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.0.22, )",
|
||||
"resolved": "1.0.22",
|
||||
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.10",
|
||||
"Microsoft.EntityFrameworkCore.Analyzers": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Relational": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Sqlite": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "kzg9MuQNJvZQxAU+piSkEzc7/1tpW6n1nVSGGMObu2GgxLK8Nf+6fvZundaznTZ+O2KhfPZ8HFNCzMH3PWDUmA==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.DependencyModel": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10",
|
||||
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.11",
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[26.4.0, )",
|
||||
"resolved": "26.4.0",
|
||||
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
|
||||
"dependencies": {
|
||||
"libsodium": "[1.0.22, 1.0.23)"
|
||||
}
|
||||
},
|
||||
"SQLitePCLRaw.bundle_e_sqlite3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.lib.e_sqlite3": "2.1.12",
|
||||
"SQLitePCLRaw.provider.e_sqlite3": "2.1.12"
|
||||
}
|
||||
},
|
||||
"SQLitePCLRaw.core": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg=="
|
||||
},
|
||||
"SQLitePCLRaw.lib.e_sqlite3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
|
||||
},
|
||||
"SQLitePCLRaw.provider.e_sqlite3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,18 @@ public sealed class SerializationTests
|
||||
[Fact]
|
||||
public void Properties_AreCamelCase()
|
||||
{
|
||||
var payload = new EncryptedPayload([1, 2, 3], KeyGeneration: 4, AadVersion: 1);
|
||||
var payload = new EncryptedPayload(
|
||||
[1, 2, 3],
|
||||
WrappedDataKey: [4, 5],
|
||||
DataKeyId: Guid.CreateVersion7(),
|
||||
KeyGeneration: 4,
|
||||
AadVersion: 1);
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, Options);
|
||||
|
||||
json.ShouldContain("\"envelope\"");
|
||||
json.ShouldContain("\"wrappedDataKey\"");
|
||||
json.ShouldContain("\"dataKeyId\"");
|
||||
json.ShouldContain("\"keyGeneration\"");
|
||||
json.ShouldContain("\"aadVersion\"");
|
||||
}
|
||||
@@ -32,11 +39,16 @@ public sealed class SerializationTests
|
||||
[Fact]
|
||||
public void ByteArrays_AreBase64()
|
||||
{
|
||||
var payload = new EncryptedPayload([0xDE, 0xAD, 0xBE, 0xEF], 1, 1);
|
||||
var payload = new EncryptedPayload(
|
||||
[0xDE, 0xAD, 0xBE, 0xEF], [0xC0, 0xFF, 0xEE], Guid.CreateVersion7(), 1, 1);
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, Options);
|
||||
|
||||
json.ShouldContain(Convert.ToBase64String([0xDE, 0xAD, 0xBE, 0xEF]));
|
||||
|
||||
// The data key wrap is a second envelope and must travel the same way. A string here
|
||||
// instead would mean a client silently storing an item nobody can ever open.
|
||||
json.ShouldContain(Convert.ToBase64String([0xC0, 0xFF, 0xEE]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -64,13 +76,16 @@ public sealed class SerializationTests
|
||||
[Fact]
|
||||
public void EncryptedPayload_RoundTrips()
|
||||
{
|
||||
var original = new EncryptedPayload([9, 8, 7, 6, 5], 12, 1);
|
||||
var original = new EncryptedPayload(
|
||||
[9, 8, 7, 6, 5], [1, 2, 3, 4], Guid.CreateVersion7(), 12, 1);
|
||||
|
||||
var restored = JsonSerializer.Deserialize<EncryptedPayload>(
|
||||
JsonSerializer.Serialize(original, Options), Options);
|
||||
|
||||
restored.ShouldNotBeNull();
|
||||
restored.Envelope.ShouldBe(original.Envelope);
|
||||
restored.WrappedDataKey.ShouldBe(original.WrappedDataKey);
|
||||
restored.DataKeyId.ShouldBe(original.DataKeyId);
|
||||
restored.KeyGeneration.ShouldBe(original.KeyGeneration);
|
||||
restored.AadVersion.ShouldBe(original.AadVersion);
|
||||
}
|
||||
@@ -86,7 +101,7 @@ public sealed class SerializationTests
|
||||
Guid.CreateVersion7(),
|
||||
SyncOperation.Upsert,
|
||||
ExpectedVersion: 3,
|
||||
Payload: new EncryptedPayload([1, 2, 3], 2, 1),
|
||||
Payload: new EncryptedPayload([1, 2, 3], [4, 5], Guid.CreateVersion7(), 2, 1),
|
||||
PlaintextFields: new SyncPlaintextFields(
|
||||
RelayEnabled: true,
|
||||
Hostname: "bastion.internal",
|
||||
|
||||
@@ -91,6 +91,8 @@ public sealed class CryptoSpecTests
|
||||
[InlineData(CryptoSpec.AadResourceType.Snippet, 9)]
|
||||
[InlineData(CryptoSpec.AadResourceType.PortForward, 10)]
|
||||
[InlineData(CryptoSpec.AadResourceType.KnownHostKey, 11)]
|
||||
[InlineData(CryptoSpec.AadResourceType.HostTag, 12)]
|
||||
[InlineData(CryptoSpec.AadResourceType.HostCredential, 13)]
|
||||
public void AadResourceType_HasStableWireValue(CryptoSpec.AadResourceType type, int expected)
|
||||
{
|
||||
((int)type).ShouldBe(expected);
|
||||
|
||||
Reference in New Issue
Block a user