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..23f7bd4 --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/RemoteShardCacheTests.cs @@ -0,0 +1,99 @@ +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); + Assert.IsFalse(reloaded.RecoveredFromBackup); + 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.IsTrue(recovered.RecoveredFromBackup); + 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..e719cc3 --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCoordinatorHistoryResumeTests.cs @@ -0,0 +1,395 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Runtime.Serialization; +using System.Text.Json; +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 void StartupWithPrimaryCache_PreservesTrustedHistoryCursor() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + SaveSynchronizedState(directory.Path, cursor: 1295, isEnabled: true); + SaveCredentials(directory.Path); + var cache = new RemoteShardCache(directory.Path); + cache.Apply(CreateCachedRecord(revision: 2, ciphertextHash: "hash-2", keyPresses: 20)); + using var transport = new RecordingTransport(); + + using var coordinator = CreateCoordinator(directory.Path, transport); + + var state = new SyncStateStore(directory.Path).Load(); + Assert.AreEqual(1295L, state.HistoryCursor); + Assert.IsFalse(state.NeedsHistoryBootstrap); + } + + [TestMethod] + public async Task StartupWithRecoveredCache_ReplaysHistoryFromZeroAndPreservesLocalDataAndCredentials() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + SaveSynchronizedState(directory.Path, cursor: 1295, isEnabled: true); + SaveCredentials(directory.Path); + var localStats = new DailyStats(new DateTime(2026, 7, 18)) { KeyPresses = 73 }; + var statsManager = CreateStatsManager(localStats); + var crypto = new SyncCrypto(); + var vaultSeed = new byte[16]; + var dataKey = crypto.DeriveDataKey(vaultSeed); + var indexKey = crypto.DeriveIndexKey(vaultSeed); + const string remoteDeviceId = "remote-device"; + const string localDay = "2026-07-13"; + var recordId = crypto.CreateRecordId(indexKey, remoteDeviceId, localDay); + var revision2 = CreateEncryptedRecord( + crypto, dataKey, recordId, remoteDeviceId, localDay, revision: 2, keyPresses: 20); + var revision3 = CreateEncryptedRecord( + crypto, dataKey, recordId, remoteDeviceId, localDay, revision: 3, keyPresses: 30); + SeedRolledBackCache(directory.Path, revision2, keyPresses: 20); + var transport = new RecordingTransport(new HistoryResponse + { + Cursor = 1295, + HasMore = false, + Changes = new List + { + new() { Cursor = 1157, RecordId = recordId, Record = revision2 }, + new() { Cursor = 1158, RecordId = recordId, Record = revision3 } + } + }); + + using (var coordinator = new SyncCoordinator(statsManager, directory.Path, null, "test", transport)) + { + var resetState = new SyncStateStore(directory.Path).Load(); + Assert.AreEqual(0L, resetState.HistoryCursor); + Assert.IsTrue(resetState.NeedsHistoryBootstrap); + Assert.IsTrue(resetState.IsEnabled); + Assert.AreEqual(73, statsManager.CurrentStats.KeyPresses); + var credentials = new SyncCredentialStore(directory.Path).Load("vault-id", "device-id"); + Assert.AreEqual("device-id.test-token", credentials.DeviceToken); + CollectionAssert.AreEqual(vaultSeed, credentials.VaultSeed); + + await coordinator.RetryBootstrapAsync().ConfigureAwait(false); + } + + CollectionAssert.AreEqual(new long[] { 0 }, transport.HistoryCursors.ToArray()); + var completed = new SyncStateStore(directory.Path).Load(); + Assert.AreEqual(1295L, completed.HistoryCursor); + Assert.IsFalse(completed.NeedsHistoryBootstrap); + var cached = new RemoteShardCache(directory.Path).GetAll().Single(record => record.RecordId == recordId); + Assert.AreEqual(3L, cached.Revision); + Assert.AreEqual(30L, cached.Plaintext.KeyPresses); + Assert.AreEqual(73, statsManager.CurrentStats.KeyPresses); + } + + [TestMethod] + public void StartupWithRecoveredCache_WhenSyncDisabled_DoesNotEnableOrBootstrapSync() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + SaveSynchronizedState(directory.Path, cursor: 1295, isEnabled: false); + SeedRolledBackCache( + directory.Path, + CreateEncryptedRecordForCacheSeed(revision: 2, keyPresses: 20), + keyPresses: 20); + using var transport = new RecordingTransport(); + + using var coordinator = CreateCoordinator(directory.Path, transport); + + var state = new SyncStateStore(directory.Path).Load(); + Assert.IsFalse(state.IsEnabled); + Assert.AreEqual(1295L, state.HistoryCursor); + Assert.IsFalse(state.NeedsHistoryBootstrap); + } + + [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 StatsManager CreateStatsManager(DailyStats currentStats) + { + var manager = (StatsManager)FormatterServices.GetUninitializedObject(typeof(StatsManager)); + SetField(manager, "_lock", new object()); + SetField(manager, "k__BackingField", currentStats); + 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 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 + { + VaultId = "vault-id", + DeviceId = "device-id", + VaultSeed = new byte[16], + DeviceToken = "device-id.test-token" + }); + } + + private static void SaveSynchronizedState(string dataFolder, long cursor, bool isEnabled) + { + new SyncStateStore(dataFolder).Save(new SyncState + { + IsEnabled = isEnabled, + NeedsBootstrap = false, + NeedsHistoryBootstrap = false, + VaultId = isEnabled ? "vault-id" : string.Empty, + DeviceId = isEnabled ? "device-id" : string.Empty, + InstallationDeviceId = "11111111-1111-4111-8111-111111111111", + DeviceName = "Test device", + ActiveDeviceCount = isEnabled ? 2 : 0, + RemainingDailySyncs = 8, + LastSuccessfulSyncAtUtc = DateTime.UtcNow, + HistoryCursor = cursor + }); + } + + private static void SeedRolledBackCache( + string dataFolder, + EncryptedSyncRecord revision2, + long keyPresses) + { + var cache = new RemoteShardCache(dataFolder); + cache.Apply(CreateCachedRecord(revision: 1, ciphertextHash: "hash-1", keyPresses: 10, + recordId: revision2.RecordId, deviceId: revision2.DeviceId)); + cache.Apply(CreateCachedRecord(revision2.Revision, revision2.CiphertextHash, keyPresses, + revision2.RecordId, revision2.DeviceId)); + cache.Apply(CreateCachedRecord(revision: 3, ciphertextHash: "discarded-primary", keyPresses: 25, + recordId: revision2.RecordId, deviceId: revision2.DeviceId)); + File.WriteAllText(Path.Combine(dataFolder, "sync_cache.json"), "damaged"); + } + + private static CachedRemoteRecord CreateCachedRecord( + long revision, + string ciphertextHash, + long keyPresses, + string recordId = "record-1", + string deviceId = "remote-device") + { + return new CachedRemoteRecord + { + RecordId = recordId, + DeviceId = deviceId, + Revision = revision, + CiphertextHash = ciphertextHash, + Plaintext = new CoreDaySnapshotV1 + { + DeviceId = deviceId, + LocalDay = "2026-07-13", + Revision = revision, + KeyPresses = keyPresses, + KeyPressCounts = new Dictionary(StringComparer.Ordinal) { ["A"] = keyPresses }, + Clicks = new CoreClickSnapshotV1() + } + }; + } + + private static EncryptedSyncRecord CreateEncryptedRecord( + SyncCrypto crypto, + byte[] dataKey, + string recordId, + string deviceId, + string localDay, + long revision, + long keyPresses) + { + var snapshot = new CoreDaySnapshotV1 + { + DeviceId = deviceId, + LocalDay = localDay, + Revision = revision, + KeyPresses = keyPresses, + KeyPressCounts = new Dictionary(StringComparer.Ordinal) { ["A"] = keyPresses }, + Clicks = new CoreClickSnapshotV1() + }; + var bytes = JsonSerializer.SerializeToUtf8Bytes(snapshot, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + return crypto.EncryptRecord(dataKey, "vault-id", deviceId, recordId, revision, bytes); + } + + private static EncryptedSyncRecord CreateEncryptedRecordForCacheSeed(long revision, long keyPresses) + { + var crypto = new SyncCrypto(); + var dataKey = crypto.DeriveDataKey(new byte[16]); + const string deviceId = "remote-device"; + const string localDay = "2026-07-13"; + var recordId = crypto.CreateRecordId(crypto.DeriveIndexKey(new byte[16]), deviceId, localDay); + return CreateEncryptedRecord(crypto, dataKey, recordId, deviceId, localDay, revision, keyPresses); + } + + private sealed class RecordingTransport : ISyncTransport + { + private readonly Queue _historyResults; + + public int SyncCallCount { get; private set; } + public List HistoryCursors { get; } = new(); + + public RecordingTransport(params object[] historyResults) + { + _historyResults = new Queue(historyResults); + } + + public Task SyncAsync( + SyncRequest request, + string deviceToken, + string idempotencyKey, + CancellationToken cancellationToken) + { + SyncCallCount++; + throw new AssertFailedException("History-only recovery must not POST /sync."); + } + + public Task GetHistoryAsync( + long cursor, + string deviceToken, + CancellationToken cancellationToken) + { + HistoryCursors.Add(cursor); + if (_historyResults.Count == 0) + { + throw new AssertFailedException("The test did not configure another history response."); + } + var result = _historyResults.Dequeue(); + if (result is Exception exception) return Task.FromException(exception); + return Task.FromResult((HistoryResponse)result); + } + + public Task GetStateAsync( + string deviceToken, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task CreateVaultAsync( + CreateVaultRequest request, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task RecoverVaultAsync( + RecoverVaultRequest request, + CancellationToken cancellationToken) => 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 RevokeDeviceAsync( + string deviceId, + string deviceToken, + CancellationToken cancellationToken) => throw Unexpected(); + + public Task DeleteVaultAsync( + string deviceToken, + CancellationToken cancellationToken) => throw Unexpected(); + + public void Dispose() + { + } + + private static InvalidOperationException Unexpected() + => new("Unexpected transport call in history resume test."); + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/SyncCredentialStoreTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCredentialStoreTests.cs new file mode 100644 index 0000000..a36fe0e --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCredentialStoreTests.cs @@ -0,0 +1,112 @@ +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using KeyStats.Models; +using KeyStats.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +[TestClass] +public sealed class SyncCredentialStoreTests +{ + [TestMethod] + public void SaveAndLoad_RoundTripsWithCurrentUserDpapi() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var store = new SyncCredentialStore(directory.Path); + var expected = new SyncCredentials + { + VaultId = "vault-id", + DeviceId = "device-id", + VaultSeed = Enumerable.Range(0, 16).Select(value => (byte)value).ToArray(), + DeviceToken = "device-id.test-token" + }; + + store.Save(expected); + var actual = store.Load("vault-id", "device-id"); + + Assert.AreEqual(expected.VaultId, actual.VaultId); + Assert.AreEqual(expected.DeviceId, actual.DeviceId); + CollectionAssert.AreEqual(expected.VaultSeed, actual.VaultSeed); + Assert.AreEqual(expected.DeviceToken, actual.DeviceToken); + } + + [TestMethod] + public void Load_WithDamagedPrimary_RestoresDpapiBackup() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var store = new SyncCredentialStore(directory.Path); + var first = new SyncCredentials + { + VaultSeed = Enumerable.Range(0, 16).Select(value => (byte)value).ToArray(), + DeviceToken = "device-id.first-token" + }; + store.Save(first); + store.Save(new SyncCredentials + { + VaultSeed = Enumerable.Repeat((byte)7, 16).ToArray(), + DeviceToken = "device-id.second-token" + }); + File.WriteAllBytes(Path.Combine(directory.Path, "sync_credentials.bin"), new byte[] { 1, 2, 3 }); + + var recovered = store.Load(); + + CollectionAssert.AreEqual(first.VaultSeed, recovered.VaultSeed); + Assert.AreEqual(first.DeviceToken, recovered.DeviceToken); + } + + [TestMethod] + public void Load_WithBackupForDifferentDevice_RejectsDeviceBinding() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var store = new SyncCredentialStore(directory.Path); + store.Save(new SyncCredentials + { + VaultSeed = Enumerable.Range(0, 16).Select(value => (byte)value).ToArray(), + DeviceToken = "old-device.old-token" + }); + store.Save(new SyncCredentials + { + VaultSeed = Enumerable.Repeat((byte)7, 16).ToArray(), + DeviceToken = "current-device.current-token" + }); + File.WriteAllBytes(Path.Combine(directory.Path, "sync_credentials.bin"), new byte[] { 1, 2, 3 }); + + Assert.ThrowsExactly(() => store.Load("current-device")); + } + + [TestMethod] + public void PendingPairingCompletion_RoundTripsSecretsWithCurrentUserDpapi() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var store = new SyncPendingSecretsStore(directory.Path); + var expected = new PendingSyncSecrets + { + Kind = "pairing-final", + VaultId = "vault-id", + DeviceId = "device-id", + VaultSeed = Enumerable.Range(0, 16).Select(value => (byte)value).ToArray(), + DeviceToken = "device-id.test-token", + PairingPrivateKey = Enumerable.Repeat((byte)3, 32).ToArray(), + PairingPublicKey = Enumerable.Repeat((byte)7, 32).ToArray(), + PairingSessionId = "session-id", + PairingCompletionToken = "completion-token" + }; + + store.Save(expected); + var actual = store.Load(); + + Assert.AreEqual(expected.Kind, actual.Kind); + Assert.AreEqual(expected.VaultId, actual.VaultId); + Assert.AreEqual(expected.DeviceId, actual.DeviceId); + Assert.AreEqual(expected.DeviceToken, actual.DeviceToken); + CollectionAssert.AreEqual(expected.VaultSeed, actual.VaultSeed); + CollectionAssert.AreEqual(expected.PairingPrivateKey, actual.PairingPrivateKey); + CollectionAssert.AreEqual(expected.PairingPublicKey, actual.PairingPublicKey); + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/SyncCryptoFixtureTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCryptoFixtureTests.cs new file mode 100644 index 0000000..7e59f29 --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/SyncCryptoFixtureTests.cs @@ -0,0 +1,144 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using KeyStats.Models; +using KeyStats.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +[TestClass] +public sealed class SyncCryptoFixtureTests +{ + [TestMethod] + public void RecoveryAndDerivedKeys_MatchSharedFixture() + { + var root = LoadFixture(); + var recovery = root.GetProperty("recovery"); + var derivedKeys = root.GetProperty("derivedKeys"); + var seed = HexToBytes(RequiredString(recovery, "seedHex")); + var crypto = new SyncCrypto(); + + Assert.AreEqual(RequiredString(recovery, "formattedCode"), crypto.EncodeRecoveryCode(seed)); + Assert.IsTrue(crypto.TryDecodeRecoveryCode( + RequiredString(recovery, "formattedCode").ToLowerInvariant(), + out var decodedSeed)); + CollectionAssert.AreEqual(seed, decodedSeed); + Assert.AreEqual( + RequiredString(derivedKeys, "encryptionKeyHex"), + ToHex(crypto.DeriveDataKey(seed))); + Assert.AreEqual( + RequiredString(derivedKeys, "recordIndexKeyHex"), + ToHex(crypto.DeriveIndexKey(seed))); + Assert.AreEqual( + RequiredString(derivedKeys, "recoveryAuthKeyHex"), + ToHex(crypto.DeriveRecoveryAuth(seed))); + Assert.AreEqual( + RequiredString(derivedKeys, "recoveryCredentialBase64Url"), + SyncCrypto.Base64UrlEncode(crypto.DeriveRecoveryAuth(seed))); + } + + [TestMethod] + public void RecordIdAndAesGcmDecryption_MatchSharedFixture() + { + var root = LoadFixture(); + var derivedKeys = root.GetProperty("derivedKeys"); + var vector = root.GetProperty("record"); + var crypto = new SyncCrypto(); + var indexKey = HexToBytes(RequiredString(derivedKeys, "recordIndexKeyHex")); + var dataKey = HexToBytes(RequiredString(derivedKeys, "encryptionKeyHex")); + var deviceId = RequiredString(vector, "deviceId"); + var recordId = RequiredString(vector, "recordId"); + + Assert.AreEqual( + recordId, + crypto.CreateRecordId(indexKey, deviceId, RequiredString(vector, "localDay"))); + + var encryptedRecord = CreateEncryptedRecord(vector); + var plaintext = crypto.DecryptRecord( + dataKey, + RequiredString(vector, "vaultId"), + encryptedRecord); + Assert.AreEqual(RequiredString(vector, "plaintextUtf8"), Encoding.UTF8.GetString(plaintext)); + + encryptedRecord.Revision++; + Assert.ThrowsExactly(() => crypto.DecryptRecord( + dataKey, + RequiredString(vector, "vaultId"), + encryptedRecord)); + } + + [TestMethod] + public void PairingDerivationSafetyCodeAndAesGcm_MatchSharedFixture() + { + var pairing = LoadFixture().GetProperty("pairing"); + var crypto = new SyncCrypto(); + var joiningPrivateKey = HexToBytes(RequiredString(pairing, "joiningPrivateKeyHex")); + var joiningPublicKey = Convert.FromBase64String(RequiredString(pairing, "joiningPublicKeyBase64")); + var approvingPublicKey = Convert.FromBase64String(RequiredString(pairing, "approvingPublicKeyBase64")); + var wrapKey = crypto.DerivePairingWrapKey(joiningPrivateKey, approvingPublicKey); + + Assert.AreEqual(RequiredString(pairing, "wrapKeyHex"), ToHex(wrapKey)); + Assert.AreEqual( + RequiredString(pairing, "safetyCode"), + crypto.CreatePairingSafetyCode(joiningPublicKey, approvingPublicKey, joiningPrivateKey)); + + var payload = new PairingEncryptedPayload + { + Nonce = RequiredString(pairing, "nonceBase64"), + Ciphertext = RequiredString(pairing, "ciphertextBase64"), + Tag = RequiredString(pairing, "tagBase64") + }; + var plaintext = crypto.DecryptPairingPayload( + wrapKey, + RequiredString(pairing, "sessionId"), + payload); + Assert.AreEqual(RequiredString(pairing, "plaintextUtf8"), Encoding.UTF8.GetString(plaintext)); + } + + private static EncryptedSyncRecord CreateEncryptedRecord(JsonElement vector) + { + return new EncryptedSyncRecord + { + SchemaVersion = 1, + RecordId = RequiredString(vector, "recordId"), + DeviceId = RequiredString(vector, "deviceId"), + Revision = vector.GetProperty("revision").GetInt64(), + Nonce = RequiredString(vector, "nonceBase64"), + Ciphertext = RequiredString(vector, "ciphertextBase64"), + Tag = RequiredString(vector, "tagBase64"), + CiphertextHash = RequiredString(vector, "ciphertextHashBase64Url") + }; + } + + private static JsonElement LoadFixture() + { + var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "crypto-vectors.json"); + using var document = JsonDocument.Parse(File.ReadAllText(path, Encoding.UTF8)); + return document.RootElement.Clone(); + } + + private static string RequiredString(JsonElement parent, string propertyName) + { + return parent.GetProperty(propertyName).GetString() + ?? throw new InvalidDataException($"Fixture property {propertyName} is missing."); + } + + private static byte[] HexToBytes(string value) + { + if (value.Length % 2 != 0) throw new FormatException("Hex input must have an even length."); + var result = new byte[value.Length / 2]; + for (var index = 0; index < result.Length; index++) + { + result[index] = Convert.ToByte(value.Substring(index * 2, 2), 16); + } + return result; + } + + private static string ToHex(byte[] value) + { + return BitConverter.ToString(value).Replace("-", string.Empty).ToLowerInvariant(); + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/SyncSchedulingTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/SyncSchedulingTests.cs new file mode 100644 index 0000000..ac30d4f --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/SyncSchedulingTests.cs @@ -0,0 +1,97 @@ +using System; +using System.IO; +using System.Reflection; +using System.Runtime.Serialization; +using KeyStats.Models; +using KeyStats.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +[TestClass] +public sealed class SyncSchedulingTests +{ + [TestMethod] + public void DamagedRemoteCache_RequiresExplicitRepairWithoutBootstrapReplay() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + new SyncStateStore(directory.Path).Save(new SyncState + { + IsEnabled = true, + NeedsBootstrap = false, + VaultId = "vault-id", + DeviceId = "device-id", + DeviceName = "Test device", + ActiveDeviceCount = 2, + RemainingDailySyncs = 8 + }); + new SyncCredentialStore(directory.Path).Save(new SyncCredentials + { + VaultSeed = new byte[16], + DeviceToken = "device-id.test-token" + }); + File.WriteAllText(Path.Combine(directory.Path, "sync_cache.json"), "damaged"); + + var statsManager = (StatsManager)FormatterServices.GetUninitializedObject(typeof(StatsManager)); + using var coordinator = new SyncCoordinator( + statsManager, + directory.Path, + new Uri("https://unit-tests.workers.dev/"), + "test"); + + var status = coordinator.GetStatus(); + var persisted = new SyncStateStore(directory.Path).Load(); + Assert.IsTrue(status.NeedsRepair); + Assert.IsFalse(status.CanRetryBootstrap); + Assert.IsTrue(persisted.NeedsRepair); + Assert.IsFalse(persisted.NeedsBootstrap); + Assert.IsFalse(persisted.NeedsHistoryBootstrap); + } + + [TestMethod] + public void Start_WithOneDevice_DoesNotScheduleOrEnableManualSync() + { + TestPlatform.RequireWindows(); + using var directory = new TestDirectory(); + var stateStore = new SyncStateStore(directory.Path); + stateStore.Save(new SyncState + { + IsEnabled = true, + VaultId = "vault-id", + DeviceId = "only-device", + DeviceName = "Only device", + ActiveDeviceCount = 1, + RemainingDailySyncs = 8 + }); + new SyncCredentialStore(directory.Path).Save(new SyncCredentials + { + VaultSeed = new byte[16], + DeviceToken = "only-device.test-token" + }); + + // Avoid opening or modifying the real user's statistics directory. The coordinator only + // attaches events and a display projection during construction; neither needs loaded stats. + var statsManager = (StatsManager)FormatterServices.GetUninitializedObject(typeof(StatsManager)); + using var coordinator = new SyncCoordinator( + statsManager, + directory.Path, + new Uri("https://unit-tests.workers.dev/"), + "test"); + coordinator.Start(); + + var status = coordinator.GetStatus(); + Assert.IsTrue(status.IsServiceConfigured); + Assert.IsTrue(status.IsEnabled); + Assert.AreEqual(1, status.ActiveDeviceCount); + Assert.IsFalse(status.CanSync); + Assert.IsFalse(status.CanManualSync); + Assert.IsNull(stateStore.Load().NextAutomaticSyncAtUtc); + + var timerField = typeof(SyncCoordinator).GetField( + "_automaticTimer", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(timerField); + Assert.IsNull(timerField!.GetValue(coordinator)); + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/SyncStateStoreTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/SyncStateStoreTests.cs new file mode 100644 index 0000000..7d32018 --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/SyncStateStoreTests.cs @@ -0,0 +1,101 @@ +using System.IO; +using KeyStats.Models; +using KeyStats.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +[TestClass] +public sealed class SyncStateStoreTests +{ + [TestMethod] + public void Load_WithDamagedPrimary_RestoresValidatedBackup() + { + using var directory = new TestDirectory(); + var path = Path.Combine(directory.Path, "sync_state.json"); + var store = new SyncStateStore(directory.Path); + store.Save(CreateState(activeDeviceCount: 1)); + store.Save(CreateState(activeDeviceCount: 2)); + File.WriteAllText(path, "damaged"); + + var reloadedStore = new SyncStateStore(directory.Path); + var recovered = reloadedStore.Load(); + + Assert.IsFalse(reloadedStore.NeedsRepair); + Assert.IsTrue(reloadedStore.RecoveredFromBackup); + Assert.AreEqual(1, recovered.ActiveDeviceCount); + Assert.AreEqual("device-id", recovered.DeviceId); + } + + [TestMethod] + public void Load_WithNoValidCopy_RequiresRepairWithoutOverwritingPrimary() + { + using var directory = new TestDirectory(); + var path = Path.Combine(directory.Path, "sync_state.json"); + File.WriteAllText(path, "damaged"); + + var store = new SyncStateStore(directory.Path); + var state = store.Load(); + + Assert.IsTrue(store.NeedsRepair); + Assert.IsTrue(state.NeedsRepair); + Assert.AreEqual("damaged", File.ReadAllText(path)); + } + + [TestMethod] + public void Save_HistoryOnlyBootstrapPhaseSurvivesRestart() + { + using var directory = new TestDirectory(); + var store = new SyncStateStore(directory.Path); + var state = CreateState(activeDeviceCount: 2); + state.NeedsBootstrap = false; + state.NeedsHistoryBootstrap = true; + state.HistoryCursor = 42; + + store.Save(state); + var restored = new SyncStateStore(directory.Path).Load(); + + Assert.IsFalse(restored.NeedsBootstrap); + Assert.IsTrue(restored.NeedsHistoryBootstrap); + Assert.AreEqual(42L, restored.HistoryCursor); + } + + [TestMethod] + public void Save_AcknowledgedCurrentEnvelopeSurvivesCrossDayRetry() + { + using var directory = new TestDirectory(); + var store = new SyncStateStore(directory.Path); + var state = CreateState(activeDeviceCount: 2); + state.LastAcknowledgedCurrentSnapshot = new EncryptedSyncRecord + { + RecordId = "prior-current", + DeviceId = "device-id", + Revision = 7, + Nonce = "nonce", + Ciphertext = "ciphertext", + Tag = "tag", + CiphertextHash = "hash" + }; + + store.Save(state); + var restored = new SyncStateStore(directory.Path).Load(); + + Assert.IsNotNull(restored.LastAcknowledgedCurrentSnapshot); + Assert.AreEqual("prior-current", restored.LastAcknowledgedCurrentSnapshot!.RecordId); + Assert.AreEqual(7L, restored.LastAcknowledgedCurrentSnapshot.Revision); + Assert.AreEqual("hash", restored.LastAcknowledgedCurrentSnapshot.CiphertextHash); + } + + private static SyncState CreateState(int activeDeviceCount) + { + return new SyncState + { + IsEnabled = true, + VaultId = "vault-id", + DeviceId = "device-id", + DeviceName = "Test device", + ActiveDeviceCount = activeDeviceCount, + RemainingDailySyncs = 8 + }; + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/TestDirectory.cs b/KeyStats.Windows/KeyStats.Sync.Tests/TestDirectory.cs new file mode 100644 index 0000000..8291b83 --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/TestDirectory.cs @@ -0,0 +1,32 @@ +using System; +using System.IO; + +namespace KeyStats.Sync.Tests; + +internal sealed class TestDirectory : IDisposable +{ + public string Path { get; } + + public TestDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "KeyStats.Sync.Tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/TestPlatform.cs b/KeyStats.Windows/KeyStats.Sync.Tests/TestPlatform.cs new file mode 100644 index 0000000..8f88a73 --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/TestPlatform.cs @@ -0,0 +1,15 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +internal static class TestPlatform +{ + public static void RequireWindows() + { + if (Environment.OSVersion.Platform != PlatformID.Win32NT) + { + Assert.Inconclusive("This test exercises Windows DPAPI and only runs on Windows."); + } + } +} diff --git a/KeyStats.Windows/KeyStats.sln b/KeyStats.Windows/KeyStats.sln index c1628c3..e71ebdb 100644 --- a/KeyStats.Windows/KeyStats.sln +++ b/KeyStats.Windows/KeyStats.sln @@ -1,10 +1,11 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KeyStats", "KeyStats\KeyStats.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KeyStats.Sync.Tests", "KeyStats.Sync.Tests\KeyStats.Sync.Tests.csproj", "{C73EAAC0-0DA4-4AC2-8192-A5F7E92463D3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,5 +16,9 @@ Global {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {C73EAAC0-0DA4-4AC2-8192-A5F7E92463D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C73EAAC0-0DA4-4AC2-8192-A5F7E92463D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C73EAAC0-0DA4-4AC2-8192-A5F7E92463D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C73EAAC0-0DA4-4AC2-8192-A5F7E92463D3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/KeyStats.Windows/KeyStats/App.xaml b/KeyStats.Windows/KeyStats/App.xaml index 9c5b55c..c6927f2 100644 --- a/KeyStats.Windows/KeyStats/App.xaml +++ b/KeyStats.Windows/KeyStats/App.xaml @@ -27,6 +27,10 @@ #E5E5E5 #A8FFFFFF #12000000 + #1E9E4A + #F9A825 + #C42B1C + #8A8A8A @@ -41,6 +45,10 @@ + + + + @@ -84,6 +92,18 @@ + + + + + + + + @@ -262,6 +352,42 @@ + +