using System.Security.Cryptography;
using DodoSSH.Domain.Sync;
namespace DodoSSH.Domain.Tests.Sync;
///
/// Cursor encoding and, more importantly, every way a bad cursor must be rejected.
///
///
/// The negative cases are the point. An accepted-but-wrong cursor causes silent data loss — the
/// client believes it is up to date while having skipped changes — which is strictly worse than an
/// error the client can retry from scratch.
///
public sealed class SyncCursorTests
{
private static readonly byte[] Key = RandomNumberGenerator.GetBytes(32);
private static readonly Guid VaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid OtherVaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10");
[Theory]
[InlineData(0L)]
[InlineData(1L)]
[InlineData(42L)]
[InlineData(long.MaxValue)]
public void RoundTrips(long sequence)
{
var cursor = SyncCursor.Encode(Key, VaultId, sequence);
SyncCursor.TryDecode(Key, cursor, VaultId, out var decoded).ShouldBeTrue();
decoded.ShouldBe(sequence);
}
[Fact]
public void IsDeterministic()
{
SyncCursor.Encode(Key, VaultId, 7).ShouldBe(SyncCursor.Encode(Key, VaultId, 7));
}
[Fact]
public void IsUrlSafeAndUnpadded()
{
var cursor = SyncCursor.Encode(Key, VaultId, 12345);
cursor.ShouldNotContain("+");
cursor.ShouldNotContain("/");
cursor.ShouldNotContain("=");
}
[Fact]
public void DoesNotRevealTheSequenceInPlainSight()
{
// Not a security property — the position is not secret — but it discourages clients from
// parsing or synthesising cursors, which is what the opacity is actually for.
SyncCursor.Encode(Key, VaultId, 987654).ShouldNotContain("987654");
}
[Fact]
public void RejectsATamperedTag()
{
var cursor = SyncCursor.Encode(Key, VaultId, 100);
var tampered = cursor[..^1] + (cursor[^1] == 'A' ? 'B' : 'A');
SyncCursor.TryDecode(Key, tampered, VaultId, out _).ShouldBeFalse();
}
[Fact]
public void RejectsATamperedPayload()
{
// The attack this prevents: rewriting the sequence to skip ahead, so the client never
// learns about the changes in between.
var cursor = SyncCursor.Encode(Key, VaultId, 100);
var mutated = cursor.ToCharArray();
mutated[0] = mutated[0] == 'x' ? 'y' : 'x';
SyncCursor.TryDecode(Key, new string(mutated), VaultId, out _).ShouldBeFalse();
}
[Fact]
public void RejectsACursorSignedWithAnotherKey()
{
var foreign = SyncCursor.Encode(RandomNumberGenerator.GetBytes(32), VaultId, 100);
SyncCursor.TryDecode(Key, foreign, VaultId, out _).ShouldBeFalse();
}
[Fact]
public void RejectsACursorIssuedForAnotherVault()
{
// Legitimately issued and correctly tagged, but for a different vault. Without the vault
// id inside the payload this would decode to a sequence from an unrelated log and serve
// the wrong slice of history.
var cursor = SyncCursor.Encode(Key, OtherVaultId, 100);
SyncCursor.TryDecode(Key, cursor, VaultId, out _).ShouldBeFalse();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not-base64!!")]
[InlineData("AAAA")]
[InlineData("A")]
public void RejectsMalformedInput(string? cursor)
{
SyncCursor.TryDecode(Key, cursor, VaultId, out _).ShouldBeFalse();
}
[Fact]
public void RejectsATruncatedCursor()
{
var cursor = SyncCursor.Encode(Key, VaultId, 100);
SyncCursor.TryDecode(Key, cursor[..(cursor.Length / 2)], VaultId, out _).ShouldBeFalse();
}
[Fact]
public void NeverThrowsOnClientSuppliedInput()
{
// Cursors come from clients, so rejection must be a return value rather than an exception
// that becomes a 500.
string[] hostile =
[
"\0", "…", new string('A', 10_000), "____", "----", "v1|x|y", "%%%",
];
foreach (var value in hostile)
{
Should.NotThrow(() => SyncCursor.TryDecode(Key, value, VaultId, out _));
}
}
[Fact]
public void RejectsAnUndersizedSigningKey()
{
// Misconfiguration must fail loudly at the call site rather than producing weak tags.
Should.Throw(() => SyncCursor.Encode(new byte[16], VaultId, 1));
Should.Throw(() =>
SyncCursor.TryDecode(new byte[31], "whatever", VaultId, out _));
}
[Fact]
public void RejectsANegativeSequence()
{
Should.Throw(() => SyncCursor.Encode(Key, VaultId, -1));
}
[Fact]
public void DifferentSequencesProduceDifferentCursors()
{
var first = SyncCursor.Encode(Key, VaultId, 1);
var second = SyncCursor.Encode(Key, VaultId, 2);
string.Equals(first, second, StringComparison.Ordinal).ShouldBeFalse();
}
}