diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ab3a712..7be528f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,6 +11,8 @@ jobs:
# ============================================================
build-macos:
runs-on: macos-26
+ env:
+ KEYSTATS_SYNC_SERVICE_URL: ${{ vars.KEYSTATS_SYNC_PRODUCTION_URL }}
outputs:
version: ${{ steps.version.outputs.VERSION }}
sha256: ${{ steps.sha256.outputs.SHA256 }}
@@ -46,7 +48,12 @@ jobs:
run: ./scripts/check_vendored_helper.sh
- name: Build DMG
- run: ./scripts/build_dmg.sh
+ run: |
+ if [[ ! "$KEYSTATS_SYNC_SERVICE_URL" =~ ^https://[A-Za-z0-9.-]+\.workers\.dev/?$ ]]; then
+ echo "KEYSTATS_SYNC_PRODUCTION_URL must be a production workers.dev URL."
+ exit 1
+ fi
+ ./scripts/build_dmg.sh
- name: Validate Sparkle private key
env:
@@ -93,7 +100,8 @@ jobs:
archive \
CODE_SIGN_IDENTITY="-" \
ARCHS="arm64 x86_64" \
- ONLY_ACTIVE_ARCH=NO
+ ONLY_ACTIVE_ARCH=NO \
+ KEYSTATS_SYNC_SERVICE_URL="$KEYSTATS_SYNC_SERVICE_URL"
APP_PATH="$ARCHIVE_PATH/Products/Applications/$APP_NAME.app"
cp -R "$APP_PATH" "$STAGING_DIR/"
@@ -139,6 +147,8 @@ jobs:
# ============================================================
build-windows:
runs-on: windows-latest
+ env:
+ KEYSTATS_SYNC_SERVICE_URL: ${{ vars.KEYSTATS_SYNC_PRODUCTION_URL }}
outputs:
version: ${{ steps.version.outputs.VERSION }}
@@ -168,7 +178,10 @@ jobs:
shell: pwsh
working-directory: KeyStats.Windows
run: |
- .\build.ps1 -Configuration Release
+ if ($env:KEYSTATS_SYNC_SERVICE_URL -notmatch '^https://[A-Za-z0-9.-]+\.workers\.dev/?$') {
+ throw 'KEYSTATS_SYNC_PRODUCTION_URL must be a production workers.dev URL.'
+ }
+ .\build.ps1 -Configuration Release -SyncServiceBaseUrl $env:KEYSTATS_SYNC_SERVICE_URL
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
diff --git a/.github/workflows/sync-worker-ci.yml b/.github/workflows/sync-worker-ci.yml
new file mode 100644
index 0000000..1055e7a
--- /dev/null
+++ b/.github/workflows/sync-worker-ci.yml
@@ -0,0 +1,48 @@
+name: Sync Worker CI
+
+on:
+ pull_request:
+ paths:
+ - 'contracts/sync/v1/**'
+ - 'services/sync-worker/**'
+ - '.github/workflows/sync-worker-*.yml'
+ push:
+ branches-ignore: [main]
+ paths:
+ - 'contracts/sync/v1/**'
+ - 'services/sync-worker/**'
+ - '.github/workflows/sync-worker-*.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sync-worker-ci-${{ github.ref }}
+ cancel-in-progress: true
+
+defaults:
+ run:
+ working-directory: services/sync-worker
+
+jobs:
+ validate:
+ name: Contract, migration, type and Worker tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: services/sync-worker/package-lock.json
+ - run: npm ci
+ - name: Validate schemas, OpenAPI and golden vectors
+ run: npm run contracts:check
+ - name: Apply D1 migrations to an isolated local database
+ run: npx wrangler d1 migrations apply DB --local
+ - name: Type-check Worker
+ run: npm run types:check && npm run typecheck
+ - name: Run Miniflare and unit tests
+ run: npm test
diff --git a/.github/workflows/sync-worker-deploy-production.yml b/.github/workflows/sync-worker-deploy-production.yml
new file mode 100644
index 0000000..7085efb
--- /dev/null
+++ b/.github/workflows/sync-worker-deploy-production.yml
@@ -0,0 +1,68 @@
+name: Deploy Sync Worker Production
+
+on:
+ workflow_dispatch:
+ inputs:
+ confirmation:
+ description: 'Type deploy-production after staging verification'
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sync-worker-production
+ cancel-in-progress: false
+
+defaults:
+ run:
+ working-directory: services/sync-worker
+
+jobs:
+ authorize:
+ name: Validate explicit confirmation
+ runs-on: ubuntu-latest
+ steps:
+ - name: Require exact production confirmation
+ if: ${{ inputs.confirmation != 'deploy-production' }}
+ run: exit 1
+
+ deploy:
+ name: Validate and deploy production
+ needs: authorize
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ environment: sync-production
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ CLOUDFLARE_D1_DATABASE_ID: ${{ vars.CLOUDFLARE_D1_DATABASE_ID }}
+ TOKEN_HASH_KEY: ${{ secrets.TOKEN_HASH_KEY }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: services/sync-worker/package-lock.json
+ - run: npm ci
+ - run: npm run check
+ - name: Validate deployment secrets
+ run: |
+ if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then
+ echo "Cloudflare deployment credentials are not configured."
+ exit 1
+ fi
+ if [ "${#TOKEN_HASH_KEY}" -lt 32 ]; then
+ echo "TOKEN_HASH_KEY must contain at least 32 characters."
+ exit 1
+ fi
+ - name: Prepare bound production configuration
+ run: node scripts/write-deploy-config.mjs production wrangler.deploy.json
+ - name: Apply production D1 migrations
+ run: npx wrangler d1 migrations apply DB --env production --remote --config wrangler.deploy.json
+ - name: Configure token hashing secret
+ run: printf '%s' "$TOKEN_HASH_KEY" | npx wrangler secret put TOKEN_HASH_KEY --env production --config wrangler.deploy.json
+ - name: Deploy production Worker
+ run: npx wrangler deploy --env production --config wrangler.deploy.json
diff --git a/.github/workflows/sync-worker-deploy-staging.yml b/.github/workflows/sync-worker-deploy-staging.yml
new file mode 100644
index 0000000..4598567
--- /dev/null
+++ b/.github/workflows/sync-worker-deploy-staging.yml
@@ -0,0 +1,59 @@
+name: Deploy Sync Worker Staging
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'contracts/sync/v1/**'
+ - 'services/sync-worker/**'
+ - '.github/workflows/sync-worker-*.yml'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sync-worker-staging
+ cancel-in-progress: false
+
+defaults:
+ run:
+ working-directory: services/sync-worker
+
+jobs:
+ deploy:
+ name: Validate and deploy staging
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ environment: sync-staging
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ CLOUDFLARE_D1_DATABASE_ID: ${{ vars.CLOUDFLARE_D1_DATABASE_ID }}
+ TOKEN_HASH_KEY: ${{ secrets.TOKEN_HASH_KEY }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: services/sync-worker/package-lock.json
+ - run: npm ci
+ - run: npm run check
+ - name: Validate deployment secrets
+ run: |
+ if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then
+ echo "Cloudflare deployment credentials are not configured."
+ exit 1
+ fi
+ if [ "${#TOKEN_HASH_KEY}" -lt 32 ]; then
+ echo "TOKEN_HASH_KEY must contain at least 32 characters."
+ exit 1
+ fi
+ - name: Prepare bound staging configuration
+ run: node scripts/write-deploy-config.mjs staging wrangler.deploy.json
+ - name: Apply staging D1 migrations
+ run: npx wrangler d1 migrations apply DB --env staging --remote --config wrangler.deploy.json
+ - name: Configure token hashing secret
+ run: printf '%s' "$TOKEN_HASH_KEY" | npx wrangler secret put TOKEN_HASH_KEY --env staging --config wrangler.deploy.json
+ - name: Deploy staging Worker
+ run: npx wrangler deploy --env staging --config wrangler.deploy.json
diff --git a/.github/workflows/windows-sync-tests.yml b/.github/workflows/windows-sync-tests.yml
new file mode 100644
index 0000000..ab7a4ea
--- /dev/null
+++ b/.github/workflows/windows-sync-tests.yml
@@ -0,0 +1,35 @@
+name: Windows Sync Tests
+
+on:
+ pull_request:
+ paths:
+ - 'KeyStats.Windows/**'
+ - 'contracts/sync/v1/**'
+ - '.github/workflows/windows-sync-tests.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'KeyStats.Windows/**'
+ - 'contracts/sync/v1/**'
+ - '.github/workflows/windows-sync-tests.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: windows-sync-tests-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ name: .NET Framework sync tests
+ runs-on: windows-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+ - name: Run Windows sync tests
+ run: dotnet test KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj --configuration Release --verbosity normal
diff --git a/AGENTS.md b/AGENTS.md
index 4a12e75..502dd45 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -99,6 +99,19 @@ Issue type?
- ✅ Implement Codable for data structures needing persistence
- ✅ Batch UI updates to reduce main thread blocking
+### 🟣 Automatic Commit Policy
+
+- ✅ After a feature or module is fully implemented and its relevant checks pass, automatically create an atomic Git commit without waiting for an additional user request
+- ✅ Before committing, inspect `git status` again and include only files changed for the completed feature or module
+- ✅ Do not automatically commit incomplete work, failed verification, or unrelated existing changes
+- ✅ Follow the repository's commit message and Git safety rules for every automatic commit
+
+### ☁️ Sync Worker Staging Deployment
+
+- ✅ After changing the staging sync service (`services/sync-worker/**`, `contracts/sync/v1/**`, or its staging workflow) and passing the relevant checks, deploy the staging Worker directly without waiting for additional confirmation
+- ✅ Apply pending staging D1 migrations before deploying, and always target the explicit `staging` Wrangler environment
+- ✅ Never deploy the production Worker without explicit user authorization in the current conversation
+
---
## Build & Development Commands
diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/AssemblyAttributes.cs b/KeyStats.Windows/KeyStats.Sync.Tests/AssemblyAttributes.cs
new file mode 100644
index 0000000..3119b2d
--- /dev/null
+++ b/KeyStats.Windows/KeyStats.Sync.Tests/AssemblyAttributes.cs
@@ -0,0 +1,3 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+[assembly: DoNotParallelize]
diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj b/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj
new file mode 100644
index 0000000..99fc3e6
--- /dev/null
+++ b/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net48
+ 10.0
+ enable
+ disable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/RemoteShardCacheTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/RemoteShardCacheTests.cs
new file mode 100644
index 0000000..95e1366
--- /dev/null
+++ b/KeyStats.Windows/KeyStats.Sync.Tests/RemoteShardCacheTests.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using KeyStats.Models;
+using KeyStats.Services;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace KeyStats.Sync.Tests;
+
+[TestClass]
+public sealed class RemoteShardCacheTests
+{
+ [TestMethod]
+ public void SameRevision_IsIdempotentAndNewRevisionReplacesAggregate()
+ {
+ TestPlatform.RequireWindows();
+ using var directory = new TestDirectory();
+ var cache = new RemoteShardCache(directory.Path);
+ var aggregator = new DisplayStatsAggregator(cache, () => "local-device");
+ var first = CreateRecord(revision: 1, ciphertextHash: "hash-1", keyPresses: 5);
+
+ Assert.IsTrue(cache.Apply(first));
+ Assert.IsFalse(cache.Apply(CreateRecord(revision: 1, ciphertextHash: "hash-1", keyPresses: 5)));
+
+ var local = new DailyStats(new DateTime(2026, 7, 13))
+ {
+ KeyPresses = 2,
+ LeftClicks = 1,
+ KeyPressCounts = new Dictionary(StringComparer.Ordinal) { ["A"] = 2 }
+ };
+ var firstAggregate = aggregator.Aggregate(local.Date, local);
+ Assert.AreEqual(7, firstAggregate.KeyPresses);
+ Assert.AreEqual(4, firstAggregate.LeftClicks);
+ Assert.AreEqual(7, firstAggregate.KeyPressCounts["A"]);
+
+ Assert.IsTrue(cache.Apply(CreateRecord(revision: 2, ciphertextHash: "hash-2", keyPresses: 8)));
+ Assert.IsFalse(cache.Apply(CreateRecord(revision: 2, ciphertextHash: "hash-2", keyPresses: 8)));
+ var secondAggregate = aggregator.Aggregate(local.Date, local);
+ Assert.AreEqual(10, secondAggregate.KeyPresses);
+ Assert.AreEqual(7, secondAggregate.LeftClicks);
+ Assert.AreEqual(10, secondAggregate.KeyPressCounts["A"]);
+ Assert.AreEqual(1, cache.GetAll().Count);
+
+ Assert.ThrowsExactly(() =>
+ cache.Apply(CreateRecord(revision: 2, ciphertextHash: "conflict", keyPresses: 8)));
+ }
+
+ [TestMethod]
+ public void Save_IsDurableAndReloadsLatestProtectedCache()
+ {
+ TestPlatform.RequireWindows();
+ using var directory = new TestDirectory();
+ var path = Path.Combine(directory.Path, "sync_cache.json");
+ var cache = new RemoteShardCache(directory.Path);
+
+ Assert.IsTrue(cache.Apply(CreateRecord(revision: 1, ciphertextHash: "hash-1", keyPresses: 5)));
+ Assert.IsTrue(cache.Apply(CreateRecord(revision: 2, ciphertextHash: "hash-2", keyPresses: 8)));
+
+ Assert.IsTrue(File.Exists(path));
+ Assert.IsTrue(File.Exists(path + ".bak"));
+ Assert.IsFalse(File.Exists(path + ".tmp"));
+
+ var reloaded = new RemoteShardCache(directory.Path);
+ Assert.IsFalse(reloaded.NeedsRepair);
+ var record = reloaded.GetAll().Single();
+ Assert.AreEqual(2L, record.Revision);
+ Assert.AreEqual("hash-2", record.CiphertextHash);
+ Assert.AreEqual(8L, record.Plaintext.KeyPresses);
+
+ File.WriteAllText(path, "damaged");
+ var recovered = new RemoteShardCache(directory.Path);
+ Assert.IsFalse(recovered.NeedsRepair);
+ Assert.AreEqual(1L, recovered.GetAll().Single().Revision);
+ }
+
+ private static CachedRemoteRecord CreateRecord(long revision, string ciphertextHash, long keyPresses)
+ {
+ return new CachedRemoteRecord
+ {
+ RecordId = "record-1",
+ DeviceId = "remote-device",
+ Revision = revision,
+ CiphertextHash = ciphertextHash,
+ IsCurrent = true,
+ Plaintext = new CoreDaySnapshotV1
+ {
+ DeviceId = "remote-device",
+ LocalDay = "2026-07-13",
+ Revision = revision,
+ KeyPresses = keyPresses,
+ KeyPressCounts = new Dictionary(StringComparer.Ordinal) { ["A"] = keyPresses },
+ Clicks = new CoreClickSnapshotV1 { Left = keyPresses - 2 }
+ }
+ };
+ }
+}
diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/SyncBatchPlannerTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/SyncBatchPlannerTests.cs
new file mode 100644
index 0000000..0d25062
--- /dev/null
+++ b/KeyStats.Windows/KeyStats.Sync.Tests/SyncBatchPlannerTests.cs
@@ -0,0 +1,189 @@
+using System;
+using System.Linq;
+using System.Reflection;
+using KeyStats.Models;
+using KeyStats.Services;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace KeyStats.Sync.Tests;
+
+[TestClass]
+public sealed class SyncBatchPlannerTests
+{
+ [TestMethod]
+ public void SyncProgress_AdvancesByDayAndStopsAtTotal()
+ {
+ var progress = new SyncProgress(18);
+
+ progress.Advance(16);
+ Assert.AreEqual(16, progress.CompletedDays);
+
+ progress.Advance(16);
+ Assert.AreEqual(18, progress.CompletedDays);
+ }
+
+ [DataTestMethod]
+ [DataRow(0, 1, "0")]
+ [DataRow(16, 1, "16")]
+ [DataRow(17, 2, "16,1")]
+ [DataRow(35, 3, "16,16,3")]
+ public void Bootstrap_SplitsArchivesAndOnlyMarksFinalBatchComplete(
+ int archiveCount,
+ int expectedBatchCount,
+ string expectedSizes)
+ {
+ var request = CreateRequest("bootstrap", archiveCount);
+
+ var batches = SyncBatchPlanner.CreateBatches(request);
+
+ Assert.AreEqual(expectedBatchCount, batches.Count);
+ Assert.AreEqual(expectedSizes, string.Join(",", batches.Select(batch => batch.Archives.Count)));
+ for (var index = 0; index < batches.Count - 1; index++)
+ {
+ Assert.IsFalse(batches[index].BootstrapComplete);
+ Assert.IsNull(batches[index].CurrentSnapshot);
+ Assert.IsNull(batches[index].EncryptedDeviceProfile);
+ }
+ Assert.IsTrue(batches[batches.Count - 1].BootstrapComplete);
+ Assert.AreSame(request.CurrentSnapshot, batches[batches.Count - 1].CurrentSnapshot);
+ Assert.AreSame(request.EncryptedDeviceProfile, batches[batches.Count - 1].EncryptedDeviceProfile);
+ CollectionAssert.AreEqual(
+ request.Archives.Select(record => record.RecordId).ToArray(),
+ batches.SelectMany(batch => batch.Archives).Select(record => record.RecordId).ToArray());
+ }
+
+ [TestMethod]
+ public void Bootstrap_AllowsProtocolMaximumAndRejectsOverflowBeforeSending()
+ {
+ var maximum = CreateRequest("recovery", SyncProtocol.MaximumBootstrapArchives);
+ var overflow = CreateRequest("pairing", SyncProtocol.MaximumBootstrapArchives + 1);
+
+ var batches = SyncBatchPlanner.CreateBatches(maximum);
+
+ Assert.AreEqual(SyncProtocol.MaximumBootstrapRequests, batches.Count);
+ Assert.IsTrue(batches[batches.Count - 1].BootstrapComplete);
+ Assert.ThrowsExactly(() => SyncBatchPlanner.CreateBatches(overflow));
+ }
+
+ [DataTestMethod]
+ [DataRow("manual")]
+ [DataRow("automatic")]
+ public void OrdinarySync_UsesOneCompleteOldestFirstBatch(string reason)
+ {
+ var request = CreateRequest(reason, 35);
+ request.BootstrapComplete = false;
+
+ var batches = SyncBatchPlanner.CreateBatches(request);
+
+ Assert.AreEqual(1, batches.Count);
+ Assert.IsTrue(batches[0].BootstrapComplete);
+ Assert.AreEqual(SyncProtocol.MaximumArchivesPerRequest, batches[0].Archives.Count);
+ CollectionAssert.AreEqual(
+ Enumerable.Range(0, SyncProtocol.MaximumArchivesPerRequest)
+ .Select(index => "record-" + index)
+ .ToArray(),
+ batches[0].Archives.Select(record => record.RecordId).ToArray());
+ Assert.AreSame(request.CurrentSnapshot, batches[0].CurrentSnapshot);
+ Assert.AreSame(request.EncryptedDeviceProfile, batches[0].EncryptedDeviceProfile);
+ }
+
+ [TestMethod]
+ public void OrdinarySync_CrossDayBacklogStartsWithExactAcknowledgedCurrentArchive()
+ {
+ var request = CreateRequest("manual", 35);
+ request.CurrentSnapshot = CreateRecord("today-current");
+ var acknowledgedCurrent = CreateRecord("record-34");
+ acknowledgedCurrent.Revision = 1;
+ acknowledgedCurrent.CiphertextHash = "acknowledged-current-hash";
+ request.Archives[34].Revision = 2;
+ request.Archives[34].CiphertextHash = "newer-local-history-hash";
+
+ var batches = SyncBatchPlanner.CreateBatches(request, acknowledgedCurrent);
+
+ Assert.AreEqual(1, batches.Count);
+ Assert.AreEqual(SyncProtocol.MaximumArchivesPerRequest, batches[0].Archives.Count);
+ Assert.AreSame(acknowledgedCurrent, batches[0].Archives[0]);
+ Assert.AreEqual("acknowledged-current-hash", batches[0].Archives[0].CiphertextHash);
+ CollectionAssert.AreEqual(
+ Enumerable.Range(0, SyncProtocol.MaximumArchivesPerRequest - 1)
+ .Select(index => "record-" + index)
+ .ToArray(),
+ batches[0].Archives.Skip(1).Select(record => record.RecordId).ToArray());
+ Assert.IsFalse(batches[0].Archives.Any(record =>
+ string.Equals(record.CiphertextHash, "newer-local-history-hash", StringComparison.Ordinal)));
+ Assert.AreSame(request.CurrentSnapshot, batches[0].CurrentSnapshot);
+ }
+
+ [TestMethod]
+ public void SyncRequest_DefaultsToCompletedBootstrap()
+ {
+ Assert.IsTrue(new SyncRequest().BootstrapComplete);
+ }
+
+ [TestMethod]
+ public void Transport_RejectsOversizedOrIncompleteOrdinaryRequestsBeforeNetwork()
+ {
+ using var transport = new CloudflareSyncTransport(new Uri("https://unit-tests.workers.dev/"));
+ var oversized = CreateRequest("bootstrap", SyncProtocol.MaximumArchivesPerRequest + 1);
+ var incompleteManual = CreateRequest("manual", 0);
+ incompleteManual.BootstrapComplete = false;
+
+ Assert.ThrowsExactly(() =>
+ transport.SyncAsync(oversized, "token", "key", default));
+ Assert.ThrowsExactly(() =>
+ transport.SyncAsync(incompleteManual, "token", "key", default));
+ }
+
+ [TestMethod]
+ public void IdempotencyFingerprint_IncludesBootstrapCompletionFlag()
+ {
+ var serializer = typeof(SyncCoordinator)
+ .GetMethods(BindingFlags.Static | BindingFlags.NonPublic)
+ .Single(method =>
+ method.Name == "SerializeCanonical" &&
+ method.GetParameters().Length == 1 &&
+ method.GetParameters()[0].ParameterType == typeof(SyncRequest));
+ var request = CreateRequest("bootstrap", 1);
+ request.BootstrapComplete = true;
+ var completedBytes = (byte[])serializer.Invoke(null, new object[] { request })!;
+ request.BootstrapComplete = false;
+ var incompleteBytes = (byte[])serializer.Invoke(null, new object[] { request })!;
+
+ Assert.AreNotEqual(
+ SyncCrypto.Sha256Base64Url(completedBytes),
+ SyncCrypto.Sha256Base64Url(incompleteBytes));
+ }
+
+ private static SyncRequest CreateRequest(string reason, int archiveCount)
+ {
+ return new SyncRequest
+ {
+ Reason = reason,
+ HistoryCursor = 7,
+ CurrentSnapshot = CreateRecord("current"),
+ Archives = Enumerable.Range(0, archiveCount)
+ .Select(index => CreateRecord("record-" + index))
+ .ToList(),
+ EncryptedDeviceProfile = new PairingEncryptedPayload
+ {
+ Nonce = "profile-nonce",
+ Ciphertext = "profile-ciphertext",
+ Tag = "profile-tag"
+ }
+ };
+ }
+
+ private static EncryptedSyncRecord CreateRecord(string recordId)
+ {
+ return new EncryptedSyncRecord
+ {
+ RecordId = recordId,
+ DeviceId = "device-id",
+ Revision = 1,
+ Nonce = "nonce",
+ Ciphertext = "ciphertext",
+ Tag = "tag",
+ CiphertextHash = "hash"
+ };
+ }
+}
diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/SyncCoordinatorFailureTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCoordinatorFailureTests.cs
new file mode 100644
index 0000000..899c1e6
--- /dev/null
+++ b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCoordinatorFailureTests.cs
@@ -0,0 +1,627 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Reflection;
+using System.Runtime.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using KeyStats.Models;
+using KeyStats.Services;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace KeyStats.Sync.Tests;
+
+[TestClass]
+public sealed class SyncCoordinatorFailureTests
+{
+ [TestMethod]
+ public async Task SingleDeviceConflict_PersistsGateAndStopsOrdinaryScheduling()
+ {
+ TestPlatform.RequireWindows();
+ using var directory = new TestDirectory();
+ SaveEnabledState(directory.Path);
+ SaveCredentials(directory.Path);
+ var failure = new SyncTransportException(
+ HttpStatusCode.Conflict,
+ "Single-device sync is disabled.",
+ null,
+ errorCode: "single_device_sync_disabled",
+ activeDeviceCount: 1);
+ using var transport = new FailureTransport(failure);
+ using var coordinator = CreateCoordinator(directory.Path, transport);
+ coordinator.Start();
+
+ await coordinator.SyncNowAsync().ConfigureAwait(false);
+
+ var persisted = new SyncStateStore(directory.Path).Load();
+ var status = coordinator.GetStatus();
+ Assert.AreEqual(1, transport.SyncCallCount);
+ Assert.AreEqual(1, persisted.ActiveDeviceCount);
+ Assert.IsNull(persisted.NextAutomaticSyncAtUtc);
+ Assert.AreEqual(1, status.ActiveDeviceCount);
+ Assert.IsFalse(status.CanSync);
+ Assert.IsFalse(status.CanManualSync);
+ Assert.IsNull(status.LastError);
+ }
+
+ [TestMethod]
+ public async Task SingleDeviceCodeWithoutAuthoritativeCount_RemainsAConflict()
+ {
+ TestPlatform.RequireWindows();
+ using var directory = new TestDirectory();
+ SaveEnabledState(directory.Path);
+ SaveCredentials(directory.Path);
+ using var transport = new FailureTransport(new SyncTransportException(
+ HttpStatusCode.Conflict,
+ "Untrusted single-device detail.",
+ null,
+ errorCode: "single_device_sync_disabled"));
+ using var coordinator = CreateCoordinator(directory.Path, transport);
+
+ await Assert.ThrowsExactlyAsync(
+ () => coordinator.SyncNowAsync()).ConfigureAwait(false);
+
+ Assert.AreEqual(2, new SyncStateStore(directory.Path).Load().ActiveDeviceCount);
+ Assert.IsFalse(coordinator.GetStatus().CanManualSync);
+ Assert.IsNotNull(coordinator.GetStatus().LastError);
+ }
+
+ [TestMethod]
+ public async Task ManualTransportFailure_AddsSixtySecondRetryDelay()
+ {
+ TestPlatform.RequireWindows();
+ using var directory = new TestDirectory();
+ SaveEnabledState(directory.Path);
+ SaveCredentials(directory.Path);
+ using var transport = new FailureTransport(new SyncTransportException(
+ HttpStatusCode.ServiceUnavailable,
+ "Service unavailable.",
+ null,
+ errorCode: "temporarily_unavailable"));
+ using var coordinator = CreateCoordinator(directory.Path, transport);
+ var before = DateTime.UtcNow;
+
+ await Assert.ThrowsExactlyAsync(
+ () => coordinator.SyncNowAsync()).ConfigureAwait(false);
+
+ var after = DateTime.UtcNow;
+ var retryAt = new SyncStateStore(directory.Path).Load().NextAllowedSyncAtUtc;
+ Assert.IsTrue(retryAt.HasValue);
+ Assert.IsTrue(retryAt.Value >= before.AddSeconds(55));
+ Assert.IsTrue(retryAt.Value <= after.AddSeconds(65));
+ Assert.IsFalse(coordinator.GetStatus().CanManualSync);
+ }
+
+ [TestMethod]
+ public void AutomaticFailures_RetryAfterOneHourThenSixHoursAndStopForUtcDay()
+ {
+ TestPlatform.RequireWindows();
+ using var directory = new TestDirectory();
+ SaveEnabledState(directory.Path);
+ SaveCredentials(directory.Path);
+ using var coordinator = new SyncCoordinator(
+ CreateStatsManager(),
+ directory.Path,
+ null,
+ "test");
+ var method = typeof(SyncCoordinator).GetMethod(
+ "RecordAutomaticFailureLocked",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ var stateField = typeof(SyncCoordinator).GetField(
+ "_state",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.IsNotNull(method);
+ Assert.IsNotNull(stateField);
+
+ var firstStart = new DateTime(2026, 7, 14, 12, 0, 0, DateTimeKind.Utc);
+ method!.Invoke(coordinator, new object?[] { firstStart });
+ var state = (SyncState)stateField!.GetValue(coordinator)!;
+ Assert.AreEqual(1, state.AutomaticFailureCount);
+ Assert.IsTrue(state.NextAutomaticSyncAtUtc.HasValue);
+ Assert.IsTrue(state.NextAutomaticSyncAtUtc.Value >= firstStart.AddMinutes(59));
+ Assert.IsTrue(state.NextAutomaticSyncAtUtc.Value <= firstStart.AddMinutes(61));
+
+ var secondStart = firstStart.AddSeconds(1);
+ method.Invoke(coordinator, new object?[] { secondStart });
+ Assert.AreEqual(2, state.AutomaticFailureCount);
+ Assert.IsTrue(state.NextAutomaticSyncAtUtc.HasValue);
+ Assert.IsTrue(state.NextAutomaticSyncAtUtc.Value >= secondStart.AddHours(6).AddMinutes(-1));
+ Assert.IsTrue(state.NextAutomaticSyncAtUtc.Value <= secondStart.AddHours(6).AddMinutes(1));
+
+ var thirdStart = secondStart.AddSeconds(1);
+ var nextUtcMidnight = thirdStart.Date.AddDays(1);
+ method.Invoke(coordinator, new object?[] { thirdStart });
+ Assert.AreEqual(SyncProtocol.MaximumAutomaticFailuresPerUtcDay, state.AutomaticFailureCount);
+ Assert.IsTrue(state.NextAutomaticSyncAtUtc.HasValue);
+ Assert.IsTrue(state.NextAutomaticSyncAtUtc.Value >= nextUtcMidnight);
+ Assert.IsTrue(state.NextAutomaticSyncAtUtc.Value <= nextUtcMidnight.AddHours(1));
+ }
+
+ [TestMethod]
+ public async Task Transport_PreservesValidatedSingleDeviceConflictDetails()
+ {
+ using var transport = new CloudflareSyncTransport(
+ new Uri("https://unit-tests.workers.dev/"),
+ new StaticResponseHandler(new HttpResponseMessage(HttpStatusCode.Conflict)
+ {
+ Content = new StringContent(
+ "{\"code\":\"single_device_sync_disabled\",\"activeDeviceCount\":1," +
+ "\"requestId\":\"not-exposed\"}")
+ }));
+
+ var exception = await Assert.ThrowsExactlyAsync(() =>
+ transport.SyncAsync(
+ new SyncRequest { Reason = "manual" },
+ "device.test-token",
+ "idempotency-key",
+ CancellationToken.None)).ConfigureAwait(false);
+
+ Assert.AreEqual(HttpStatusCode.Conflict, exception.StatusCode);
+ Assert.AreEqual("single_device_sync_disabled", exception.ErrorCode);
+ Assert.AreEqual(1, exception.ActiveDeviceCount!.Value);
+ Assert.AreEqual(-1, exception.Message.IndexOf("not-exposed", StringComparison.Ordinal));
+ }
+
+ [TestMethod]
+ public async Task Transport_PreservesEncryptedMaximumDeviceRecoveryOptions()
+ {
+ var vaultId = "11111111-1111-4111-8111-111111111111";
+ var deviceIds = new[]
+ {
+ "22222222-2222-4222-8222-222222222222",
+ "33333333-3333-4333-8333-333333333333",
+ "44444444-4444-4444-8444-444444444444",
+ "55555555-5555-4555-8555-555555555555",
+ "66666666-6666-4666-8666-666666666666"
+ };
+ var body = "{\"code\":\"maximum_devices\",\"vaultId\":\"" + vaultId +
+ "\",\"devices\":[" + string.Join(",", deviceIds.Select(id =>
+ "{\"deviceId\":\"" + id +
+ "\",\"encryptedDeviceProfile\":null,\"revoked\":false}")) + "]}";
+ using var transport = new CloudflareSyncTransport(
+ new Uri("https://unit-tests.workers.dev/"),
+ new StaticResponseHandler(new HttpResponseMessage(HttpStatusCode.Conflict)
+ {
+ Content = new StringContent(body)
+ }));
+
+ var exception = await Assert.ThrowsExactlyAsync(() =>
+ transport.RecoverVaultAsync(
+ new RecoverVaultRequest(),
+ CancellationToken.None)).ConfigureAwait(false);
+
+ Assert.AreEqual("maximum_devices", exception.ErrorCode);
+ Assert.AreEqual(vaultId, exception.VaultId);
+ Assert.AreEqual(SyncProtocol.MaximumDevices, exception.Devices.Count);
+ CollectionAssert.AreEqual(
+ deviceIds,
+ exception.Devices.Select(device => device.DeviceId).ToArray());
+ }
+
+ [TestMethod]
+ public async Task StartupWithCredentialBackupForAnotherDevice_BlocksVaultDeletion()
+ {
+ TestPlatform.RequireWindows();
+ using var directory = new TestDirectory();
+ SaveEnabledState(directory.Path);
+ var credentialStore = new SyncCredentialStore(directory.Path);
+ credentialStore.Save(new SyncCredentials
+ {
+ VaultSeed = new byte[16],
+ DeviceToken = "old-device.old-token"
+ });
+ credentialStore.Save(new SyncCredentials
+ {
+ VaultSeed = new byte[16],
+ DeviceToken = "device-id.current-token"
+ });
+ File.WriteAllBytes(
+ Path.Combine(directory.Path, "sync_credentials.bin"),
+ new byte[] { 1, 2, 3 });
+ using var transport = new FailureTransport(new InvalidOperationException("Not used."));
+ using var coordinator = CreateCoordinator(directory.Path, transport);
+
+ Assert.IsTrue(coordinator.GetStatus().NeedsRepair);
+ await Assert.ThrowsExactlyAsync(
+ () => coordinator.DeleteVaultAsync()).ConfigureAwait(false);
+ Assert.AreEqual(0, transport.DeleteVaultCallCount);
+ }
+
+ [TestMethod]
+ public async Task CredentialSwapAfterStartup_BlocksVaultDeletionAndPersistsRepairState()
+ {
+ TestPlatform.RequireWindows();
+ using var directory = new TestDirectory();
+ SaveEnabledState(directory.Path);
+ SaveCredentials(directory.Path);
+ using var transport = new FailureTransport(new InvalidOperationException("Not used."));
+ using var coordinator = CreateCoordinator(directory.Path, transport);
+ new SyncCredentialStore(directory.Path).Save(new SyncCredentials
+ {
+ VaultSeed = new byte[16],
+ DeviceToken = "other-device.other-token"
+ });
+
+ await Assert.ThrowsExactlyAsync(
+ () => coordinator.DeleteVaultAsync()).ConfigureAwait(false);
+
+ Assert.AreEqual(0, transport.DeleteVaultCallCount);
+ Assert.IsTrue(coordinator.GetStatus().NeedsRepair);
+ Assert.IsTrue(new SyncStateStore(directory.Path).Load().NeedsRepair);
+ }
+
+ [TestMethod]
+ public async Task SingleDeviceStateRefresh_UsesReadOnlyEndpointAndNeverPostsSync()
+ {
+ TestPlatform.RequireWindows();
+ using var directory = new TestDirectory();
+ var now = DateTime.UtcNow;
+ new SyncStateStore(directory.Path).Save(new SyncState
+ {
+ IsEnabled = true,
+ VaultId = "vault-id",
+ DeviceId = "device-id",
+ DeviceName = "Test device",
+ ActiveDeviceCount = 1,
+ RemainingDailySyncs = 8,
+ LastSuccessfulSyncAtUtc = now,
+ LastStateRefreshAtUtc = now.AddHours(-25)
+ });
+ SaveCredentials(directory.Path);
+ using var transport = new FailureTransport(new InvalidOperationException("Not used."))
+ {
+ StateResponse = new SyncStateResponse
+ {
+ ServerTime = now,
+ ActiveDeviceCount = 1,
+ Devices = new List
+ {
+ new() { DeviceId = "device-id" }
+ }
+ }
+ };
+ using var coordinator = CreateCoordinator(directory.Path, transport);
+ var refresh = typeof(SyncCoordinator).GetMethod(
+ "RefreshStateAsync",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.IsNotNull(refresh);
+
+ var task = (Task)refresh!.Invoke(
+ coordinator,
+ new object?[] { false, CancellationToken.None })!;
+ await task.ConfigureAwait(false);
+
+ Assert.AreEqual(1, transport.StateCallCount);
+ Assert.AreEqual(0, transport.SyncCallCount);
+ Assert.AreEqual(1, coordinator.GetStatus().ActiveDeviceCount);
+ Assert.IsFalse(coordinator.GetStatus().CanManualSync);
+ Assert.IsTrue(new SyncStateStore(directory.Path).Load().LastStateRefreshAtUtc.HasValue);
+ }
+
+ [TestMethod]
+ public async Task FailedVaultDeletion_PersistsIntentAndReplaysSameBoundToken()
+ {
+ TestPlatform.RequireWindows();
+ using var directory = new TestDirectory();
+ SaveEnabledState(directory.Path);
+ SaveCredentials(directory.Path);
+ using (var failingTransport = new FailureTransport(new SyncTransportException(
+ HttpStatusCode.ServiceUnavailable,
+ "Service unavailable.",
+ null,
+ errorCode: "temporarily_unavailable"))
+ { DeleteFailuresRemaining = 1 })
+ using (var coordinator = CreateCoordinator(directory.Path, failingTransport))
+ {
+ await Assert.ThrowsExactlyAsync(
+ () => coordinator.DeleteVaultAsync()).ConfigureAwait(false);
+ Assert.AreEqual(1, failingTransport.DeleteVaultCallCount);
+ Assert.AreEqual("device-id.test-token", failingTransport.LastDeleteToken);
+ Assert.IsTrue(new SyncStateStore(directory.Path).Load().PendingVaultDeletion);
+ Assert.AreEqual(0, failingTransport.SyncCallCount);
+ }
+
+ using var replayTransport = new FailureTransport(new InvalidOperationException("Not used."));
+ using var restarted = CreateCoordinator(directory.Path, replayTransport);
+ Assert.IsTrue(restarted.GetStatus().CanRetryBootstrap);
+ await restarted.RetryBootstrapAsync().ConfigureAwait(false);
+
+ Assert.AreEqual(1, replayTransport.DeleteVaultCallCount);
+ Assert.AreEqual("device-id.test-token", replayTransport.LastDeleteToken);
+ Assert.AreEqual(0, replayTransport.SyncCallCount);
+ var cleared = new SyncStateStore(directory.Path).Load();
+ Assert.IsFalse(cleared.IsEnabled);
+ Assert.IsFalse(cleared.PendingVaultDeletion);
+ Assert.AreNotEqual("device-id", cleared.InstallationDeviceId);
+ }
+
+ [TestMethod]
+ public async Task UnauthorizedRecovery_CanClearOnlyLocalSetupAndRetainsTakeoverIdentity()
+ {
+ TestPlatform.RequireWindows();
+ using var directory = new TestDirectory();
+ var installationId = "77777777-7777-4777-8777-777777777777";
+ new SyncStateStore(directory.Path).Save(new SyncState
+ {
+ InstallationDeviceId = installationId
+ });
+ using var transport = new FailureTransport(new InvalidOperationException("Not used."))
+ {
+ RecoverFailure = new SyncTransportException(
+ HttpStatusCode.Unauthorized,
+ "Unauthorized.",
+ null,
+ errorCode: "unauthorized")
+ };
+ using var coordinator = CreateCoordinator(directory.Path, transport);
+ var recoveryCode = new SyncCrypto().EncodeRecoveryCode(new byte[16]);
+
+ await Assert.ThrowsExactlyAsync(() =>
+ coordinator.RecoverVaultAsync(recoveryCode, "Test device")).ConfigureAwait(false);
+
+ var pending = new SyncStateStore(directory.Path).Load();
+ Assert.AreEqual("recover", pending.PendingProvisioningKind);
+ Assert.IsTrue(coordinator.BlocksImport);
+ await coordinator.ClearLocalSyncConfigurationAsync().ConfigureAwait(false);
+
+ var cleared = new SyncStateStore(directory.Path).Load();
+ Assert.IsFalse(cleared.IsEnabled);
+ Assert.IsNull(cleared.PendingProvisioningKind);
+ Assert.AreNotEqual(installationId, cleared.InstallationDeviceId);
+ Assert.IsNull(cleared.ReplacementCandidateDeviceId);
+ Assert.IsFalse(coordinator.BlocksImport);
+ }
+
+ [TestMethod]
+ public async Task ClearingRepair_RotatesInstallationAndRetainsOldDeviceAsTakeoverCandidate()
+ {
+ TestPlatform.RequireWindows();
+ using var directory = new TestDirectory();
+ var oldDeviceId = "88888888-8888-4888-8888-888888888888";
+ new SyncStateStore(directory.Path).Save(new SyncState
+ {
+ IsEnabled = true,
+ NeedsRepair = true,
+ VaultId = "vault-id",
+ DeviceId = oldDeviceId,
+ InstallationDeviceId = oldDeviceId,
+ DeviceName = "Broken device",
+ ActiveDeviceCount = 2
+ });
+ using var transport = new FailureTransport(new InvalidOperationException("Not used."));
+ using var coordinator = CreateCoordinator(directory.Path, transport);
+
+ await coordinator.ClearLocalSyncConfigurationAsync().ConfigureAwait(false);
+
+ var cleared = new SyncStateStore(directory.Path).Load();
+ Assert.AreNotEqual(oldDeviceId, cleared.InstallationDeviceId);
+ Assert.AreEqual(oldDeviceId, cleared.ReplacementCandidateDeviceId);
+ Assert.IsFalse(coordinator.BlocksImport);
+
+ transport.RecoverFailure = new SyncTransportException(
+ HttpStatusCode.Unauthorized,
+ "Unauthorized.",
+ null,
+ errorCode: "recovery_failed");
+ var recoveryCode = new SyncCrypto().EncodeRecoveryCode(new byte[16]);
+ await Assert.ThrowsExactlyAsync(() =>
+ coordinator.RecoverVaultAsync(recoveryCode, "Recovered device")).ConfigureAwait(false);
+ Assert.IsNotNull(transport.LastRecoverRequest);
+ Assert.AreEqual(oldDeviceId, transport.LastRecoverRequest!.DeviceId);
+ Assert.AreEqual(oldDeviceId, transport.LastRecoverRequest.ReplaceDeviceId);
+ }
+
+ [TestMethod]
+ public async Task MissingRecoveryReplacement_FallsBackOnceToFreshDeviceIdentity()
+ {
+ TestPlatform.RequireWindows();
+ using var directory = new TestDirectory();
+ var oldDeviceId = "99999999-9999-4999-8999-999999999999";
+ new SyncStateStore(directory.Path).Save(new SyncState
+ {
+ InstallationDeviceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ ReplacementCandidateDeviceId = oldDeviceId,
+ LocalRecords = new Dictionary
+ {
+ ["2026-07-14"] = new() { LocalDay = "2026-07-14", Revision = 7 }
+ }
+ });
+ using var transport = new FailureTransport(new InvalidOperationException("Not used."));
+ transport.RecoverFailures.Enqueue(new SyncTransportException(
+ HttpStatusCode.Conflict,
+ "Replacement is not in this vault.",
+ null,
+ errorCode: "replace_device_not_found"));
+ transport.RecoverFailures.Enqueue(new SyncTransportException(
+ HttpStatusCode.ServiceUnavailable,
+ "Service unavailable.",
+ null,
+ errorCode: "temporarily_unavailable"));
+ using var coordinator = CreateCoordinator(directory.Path, transport);
+ var recoveryCode = new SyncCrypto().EncodeRecoveryCode(new byte[16]);
+
+ await Assert.ThrowsExactlyAsync(() =>
+ coordinator.RecoverVaultAsync(recoveryCode, "Recovered device")).ConfigureAwait(false);
+
+ Assert.AreEqual(2, transport.RecoverRequests.Count);
+ Assert.AreEqual(oldDeviceId, transport.RecoverRequests[0].DeviceId);
+ Assert.AreEqual(oldDeviceId, transport.RecoverRequests[0].ReplaceDeviceId);
+ Assert.AreNotEqual(oldDeviceId, transport.RecoverRequests[1].DeviceId);
+ Assert.IsNull(transport.RecoverRequests[1].ReplaceDeviceId);
+ Assert.IsTrue(transport.RecoverRequests[1].DeviceToken.StartsWith(
+ transport.RecoverRequests[1].DeviceId + ".",
+ StringComparison.Ordinal));
+ var pendingState = new SyncStateStore(directory.Path).Load();
+ Assert.IsNull(pendingState.ReplacementCandidateDeviceId);
+ Assert.IsNull(pendingState.PendingProvisioningReplaceDeviceId);
+ Assert.AreEqual(0, pendingState.LocalRecords.Count);
+ }
+
+ private static SyncCoordinator CreateCoordinator(string dataFolder, ISyncTransport transport)
+ => new(CreateStatsManager(), dataFolder, null, "test", transport);
+
+ private static StatsManager CreateStatsManager()
+ {
+ var manager = (StatsManager)FormatterServices.GetUninitializedObject(typeof(StatsManager));
+ SetField(manager, "_lock", new object());
+ SetField(manager, "k__BackingField", new DailyStats(DateTime.Today));
+ SetField(
+ manager,
+ "k__BackingField",
+ new Dictionary(StringComparer.Ordinal));
+ return manager;
+ }
+
+ private static void SetField(object target, string name, object value)
+ {
+ var field = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.IsNotNull(field, "Missing test setup field: " + name);
+ field!.SetValue(target, value);
+ }
+
+ private static void SaveEnabledState(string dataFolder)
+ {
+ var now = DateTime.UtcNow;
+ new SyncStateStore(dataFolder).Save(new SyncState
+ {
+ IsEnabled = true,
+ VaultId = "vault-id",
+ DeviceId = "device-id",
+ DeviceName = "Test device",
+ ActiveDeviceCount = 2,
+ RemainingDailySyncs = 8,
+ QuotaUtcDay = now.ToString("yyyy-MM-dd"),
+ LastSuccessfulSyncAtUtc = now.AddHours(-2)
+ });
+ }
+
+ private static void SaveCredentials(string dataFolder)
+ {
+ new SyncCredentialStore(dataFolder).Save(new SyncCredentials
+ {
+ VaultSeed = new byte[16],
+ DeviceToken = "device-id.test-token"
+ });
+ }
+
+ private sealed class FailureTransport : ISyncTransport
+ {
+ private readonly Exception _failure;
+
+ public int SyncCallCount { get; private set; }
+ public int StateCallCount { get; private set; }
+ public int DeleteVaultCallCount { get; private set; }
+ public int DeleteFailuresRemaining { get; set; }
+ public string? LastDeleteToken { get; private set; }
+ public SyncStateResponse? StateResponse { get; set; }
+ public Exception? RecoverFailure { get; set; }
+ public RecoverVaultRequest? LastRecoverRequest { get; private set; }
+ public Queue RecoverFailures { get; } = new();
+ public List RecoverRequests { get; } = new();
+
+ public FailureTransport(Exception failure) => _failure = failure;
+
+ public Task SyncAsync(
+ SyncRequest request,
+ string deviceToken,
+ string idempotencyKey,
+ CancellationToken cancellationToken)
+ {
+ SyncCallCount++;
+ return Task.FromException(_failure);
+ }
+
+ public Task CreateVaultAsync(
+ CreateVaultRequest request,
+ CancellationToken cancellationToken) => throw Unexpected();
+
+ public Task RecoverVaultAsync(
+ RecoverVaultRequest request,
+ CancellationToken cancellationToken)
+ {
+ LastRecoverRequest = request;
+ RecoverRequests.Add(request);
+ if (RecoverFailures.Count > 0)
+ {
+ return Task.FromException(RecoverFailures.Dequeue());
+ }
+ return RecoverFailure != null
+ ? Task.FromException(RecoverFailure!)
+ : throw Unexpected();
+ }
+
+ public Task CreatePairingSessionAsync(
+ CreatePairingSessionRequest request,
+ CancellationToken cancellationToken) => throw Unexpected();
+
+ public Task JoinPairingSessionAsync(
+ string code,
+ JoinPairingSessionRequest request,
+ string deviceToken,
+ CancellationToken cancellationToken) => throw Unexpected();
+
+ public Task ApprovePairingSessionAsync(
+ string sessionId,
+ ApprovePairingSessionRequest request,
+ string deviceToken,
+ CancellationToken cancellationToken) => throw Unexpected();
+
+ public Task CompletePairingSessionAsync(
+ string sessionId,
+ CompletePairingSessionRequest request,
+ CancellationToken cancellationToken) => throw Unexpected();
+
+ public Task GetHistoryAsync(
+ long cursor,
+ string deviceToken,
+ CancellationToken cancellationToken) => throw Unexpected();
+
+ public Task GetStateAsync(
+ string deviceToken,
+ CancellationToken cancellationToken)
+ {
+ StateCallCount++;
+ return StateResponse != null
+ ? Task.FromResult(StateResponse)
+ : Task.FromException(Unexpected());
+ }
+
+ public Task RevokeDeviceAsync(
+ string deviceId,
+ string deviceToken,
+ CancellationToken cancellationToken) => throw Unexpected();
+
+ public Task DeleteVaultAsync(string deviceToken, CancellationToken cancellationToken)
+ {
+ DeleteVaultCallCount++;
+ LastDeleteToken = deviceToken;
+ if (DeleteFailuresRemaining > 0)
+ {
+ DeleteFailuresRemaining--;
+ return Task.FromException(_failure);
+ }
+ return Task.CompletedTask;
+ }
+
+ public void Dispose()
+ {
+ }
+
+ private static InvalidOperationException Unexpected()
+ => new("Unexpected transport call in sync failure test.");
+ }
+
+ private sealed class StaticResponseHandler : HttpMessageHandler
+ {
+ private readonly HttpResponseMessage _response;
+
+ public StaticResponseHandler(HttpResponseMessage response) => _response = response;
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken) => Task.FromResult(_response);
+ }
+}
diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/SyncCoordinatorHistoryResumeTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCoordinatorHistoryResumeTests.cs
new file mode 100644
index 0000000..cfce334
--- /dev/null
+++ b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCoordinatorHistoryResumeTests.cs
@@ -0,0 +1,184 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Runtime.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using KeyStats.Models;
+using KeyStats.Services;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace KeyStats.Sync.Tests;
+
+[TestClass]
+public sealed class SyncCoordinatorHistoryResumeTests
+{
+ [TestMethod]
+ public async Task HistoryFailure_PersistsPageCursorAndRestartNeverPostsSync()
+ {
+ TestPlatform.RequireWindows();
+ using var directory = new TestDirectory();
+ SaveHistoryOnlyState(directory.Path, cursor: 7);
+ SaveCredentials(directory.Path);
+
+ var firstTransport = new RecordingTransport(
+ new HistoryResponse { Cursor = 11, HasMore = true },
+ new SyncTransportException(
+ HttpStatusCode.ServiceUnavailable,
+ "History is temporarily unavailable.",
+ null));
+ using (var firstCoordinator = CreateCoordinator(directory.Path, firstTransport))
+ {
+ try
+ {
+ await firstCoordinator.RetryBootstrapAsync().ConfigureAwait(false);
+ Assert.Fail("The second history page should fail.");
+ }
+ catch (SyncTransportException ex)
+ {
+ Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode);
+ }
+ }
+
+ var interrupted = new SyncStateStore(directory.Path).Load();
+ Assert.IsFalse(interrupted.NeedsBootstrap);
+ Assert.IsTrue(interrupted.NeedsHistoryBootstrap);
+ Assert.AreEqual(11L, interrupted.HistoryCursor);
+ Assert.AreEqual(0, firstTransport.SyncCallCount);
+ CollectionAssert.AreEqual(new long[] { 7, 11 }, firstTransport.HistoryCursors.ToArray());
+
+ var resumedTransport = new RecordingTransport(
+ new HistoryResponse { Cursor = 15, HasMore = false });
+ using (var resumedCoordinator = CreateCoordinator(directory.Path, resumedTransport))
+ {
+ await resumedCoordinator.RetryBootstrapAsync().ConfigureAwait(false);
+ }
+
+ var completed = new SyncStateStore(directory.Path).Load();
+ Assert.IsFalse(completed.NeedsBootstrap);
+ Assert.IsFalse(completed.NeedsHistoryBootstrap);
+ Assert.AreEqual(15L, completed.HistoryCursor);
+ Assert.AreEqual(0, resumedTransport.SyncCallCount);
+ CollectionAssert.AreEqual(new long[] { 11 }, resumedTransport.HistoryCursors.ToArray());
+ }
+
+ private static SyncCoordinator CreateCoordinator(string dataFolder, ISyncTransport transport)
+ {
+ var statsManager = (StatsManager)FormatterServices.GetUninitializedObject(typeof(StatsManager));
+ return new SyncCoordinator(statsManager, dataFolder, null, "test", transport);
+ }
+
+ private static void SaveHistoryOnlyState(string dataFolder, long cursor)
+ {
+ new SyncStateStore(dataFolder).Save(new SyncState
+ {
+ IsEnabled = true,
+ NeedsBootstrap = false,
+ NeedsHistoryBootstrap = true,
+ VaultId = "vault-id",
+ DeviceId = "device-id",
+ DeviceName = "Test device",
+ ActiveDeviceCount = 2,
+ RemainingDailySyncs = 8,
+ LastSuccessfulSyncAtUtc = DateTime.UtcNow,
+ HistoryCursor = cursor
+ });
+ }
+
+ private static void SaveCredentials(string dataFolder)
+ {
+ new SyncCredentialStore(dataFolder).Save(new SyncCredentials
+ {
+ VaultSeed = new byte[16],
+ DeviceToken = "device-id.test-token"
+ });
+ }
+
+ private sealed class RecordingTransport : ISyncTransport
+ {
+ private readonly Queue