Merge branch 'claude/main-page-group-hierarchy-3a3210'
ci / build and test (push) Successful in 1m27s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 21s

This commit is contained in:
2026-08-04 10:26:20 +02:00
8 changed files with 214 additions and 33 deletions
@@ -394,6 +394,49 @@ public sealed class CacheStoreTests : IAsyncLifetime
all.Detail.ShouldBe(new byte[] { 1, 2, 3 });
}
/// <summary>
/// A conflict can be recorded against any kind of item, not only the kinds that were here first.
/// </summary>
/// <remarks>
/// <para>
/// Every other test in this section uses <see cref="SyncEntityType.Host"/>, and that is how three item
/// kinds shipped with no way to record a conflict at all: the two logs and the buckets were added to both
/// enums, to the reconciler registry and to the cipher pinning, while <c>AadResourceTypes.For</c> — which
/// <c>ConflictStore.RecordAsync</c> calls unconditionally — kept throwing for them. The two other callers
/// of that mapping only reach it when an item carries plaintext fields, which none of the three does, so
/// nothing else so much as touched the gap.
/// </para>
/// <para>
/// A theory over the types rather than one more <c>Host</c> case, because the failure was never about
/// conflicts and always about which types the layer below had been taught. Recording is asserted through
/// a read-back rather than by "it did not throw": an arm returning the wrong resource type would seal
/// under one AAD and open under another, which is a null detail rather than an exception.
/// </para>
/// </remarks>
[Theory]
[InlineData(SyncEntityType.Host)]
[InlineData(SyncEntityType.HostGroup)]
[InlineData(SyncEntityType.Snippet)]
[InlineData(SyncEntityType.ConnectionLogEntry)]
[InlineData(SyncEntityType.ActivityLogEntry)]
[InlineData(SyncEntityType.ObjectStore)]
public async Task AConflict_CanBeRecordedForEveryKindOfItem(SyncEntityType entityType)
{
var detail = System.Text.Encoding.UTF8.GetBytes($$"""{"kind":"{{entityType}}"}""");
var id = await harness.Conflicts.RecordAsync(
VaultId, entityType, Guid.CreateVersion7(), ConflictKind.FieldOverridden, detail, Token);
var listed = (await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldHaveSingleItem();
listed.Id.ShouldBe(id);
listed.EntityType.ShouldBe(entityType);
listed.Detail.ShouldBe(
detail,
"an empty detail here means the record was sealed under one resource type and opened under "
+ "another, which ListAsync reports as nothing rather than as a failure");
}
[Fact]
public async Task AnUnacknowledgedConflict_CannotBeDiscarded()
{
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
@@ -151,6 +152,63 @@ public sealed class AadResourceTypeTests
.ShouldBe(ItemKinds.SyncedTypes, ignoreOrder: true);
}
/// <summary>
/// The same pairing, made a second time in the storage layer, and every type must be in it.
/// </summary>
/// <remarks>
/// <para>
/// <c>AadResourceTypes.For</c> is the cache's copy of the table above: the ciphers seal an item for the
/// <em>server</em>, and this seals the two things the local cache holds in the clear — a relay host's
/// address, and the values a merge overrode. A type missing from it throws rather than mis-seals, which
/// sounds like the safe failure and is not: <c>ConflictStore.RecordAsync</c> calls it unconditionally, so
/// the exception lands on the path that records what a merge discarded.
/// </para>
/// <para>
/// This is written after finding three types missing from it — <c>ConnectionLogEntry</c>,
/// <c>ActivityLogEntry</c> and <c>ObjectStore</c> went two shipping phases without an arm, because the
/// only unconditional caller is one a test suite reaches solely by causing a real merge conflict. Asserted
/// over the whole wire enum rather than over <c>ItemKinds.SyncedTypes</c>, which is the stronger claim and
/// the one the switch actually makes: the two reserved association types have arms too.
/// </para>
/// </remarks>
[Fact]
public void EverySyncableType_HasAnArmInTheStorageMapping()
{
var missing = new List<SyncEntityType>();
foreach (var wire in Enum.GetValues<SyncEntityType>())
{
if (wire == SyncEntityType.Unspecified)
{
continue;
}
CryptoSpec.AadResourceType resource;
try
{
resource = AadResourceTypes.For(wire);
}
catch (ArgumentOutOfRangeException)
{
missing.Add(wire);
continue;
}
// Same name, as the cipher table demands — a wrong-but-present arm is the failure this half
// would otherwise wave through.
Enum.GetName(resource).ShouldBe(
Enum.GetName(wire),
$"AadResourceTypes.For({wire}) returns {resource}, which binds this type's cache records "
+ "to another type's resource.");
}
missing.ShouldBeEmpty(
"every syncable type needs an arm in AadResourceTypes.For, or a conflict recorded against one "
+ "of these throws instead of being written — and the conflict log is what justifies the merge "
+ "picking a winner.");
}
private static EncryptedPayload SealSample(
SyncEntityType wire,
byte[] vaultKey,