From 842baf81bf71ef1d84b2cb92d23e117e48942206 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Sat, 24 Jan 2026 16:36:49 +0100 Subject: [PATCH] fix(sync): warn about unsynced changes before password change Add check for unsynced operations before proceeding with encryption password change. If there are pending operations that haven't been synced yet, throw an error asking the user to wait for sync to complete. This prevents potential data loss where local changes would be discarded during the clean slate mechanism. --- ...encryption-password-change.service.spec.ts | 29 +++++++++++++++++++ .../encryption-password-change.service.ts | 11 +++++++ 2 files changed, 40 insertions(+) diff --git a/src/app/imex/sync/encryption-password-change.service.spec.ts b/src/app/imex/sync/encryption-password-change.service.spec.ts index 0e76459d9b..6dcc663876 100644 --- a/src/app/imex/sync/encryption-password-change.service.spec.ts +++ b/src/app/imex/sync/encryption-password-change.service.spec.ts @@ -6,6 +6,7 @@ import { OperationLogUploadService } from '../../op-log/sync/operation-log-uploa import { DerivedKeyCacheService } from '../../op-log/encryption/derived-key-cache.service'; import { SyncProviderId } from '../../op-log/sync-providers/provider.const'; import { SyncWrapperService } from './sync-wrapper.service'; +import { OperationLogStoreService } from '../../op-log/persistence/operation-log-store.service'; describe('EncryptionPasswordChangeService', () => { let service: EncryptionPasswordChangeService; @@ -14,6 +15,7 @@ describe('EncryptionPasswordChangeService', () => { let mockUploadService: jasmine.SpyObj; let mockDerivedKeyCache: jasmine.SpyObj; let mockSyncWrapper: jasmine.SpyObj; + let mockOpLogStore: jasmine.SpyObj; let mockSyncProvider: jasmine.SpyObj; const TEST_PASSWORD = 'new-secure-password-123'; @@ -67,6 +69,10 @@ describe('EncryptionPasswordChangeService', () => { }, ); + // Mock OperationLogStoreService - default to no unsynced operations + mockOpLogStore = jasmine.createSpyObj('OperationLogStoreService', ['getUnsynced']); + mockOpLogStore.getUnsynced.and.returnValue(Promise.resolve([])); + TestBed.configureTestingModule({ providers: [ EncryptionPasswordChangeService, @@ -75,6 +81,7 @@ describe('EncryptionPasswordChangeService', () => { { provide: OperationLogUploadService, useValue: mockUploadService }, { provide: DerivedKeyCacheService, useValue: mockDerivedKeyCache }, { provide: SyncWrapperService, useValue: mockSyncWrapper }, + { provide: OperationLogStoreService, useValue: mockOpLogStore }, ], }); service = TestBed.inject(EncryptionPasswordChangeService); @@ -156,6 +163,28 @@ describe('EncryptionPasswordChangeService', () => { expect(mockCleanSlateService.createCleanSlate).not.toHaveBeenCalled(); }); + it('should throw error if there are unsynced operations', async () => { + mockOpLogStore.getUnsynced.and.returnValue( + Promise.resolve([{ id: 'op1' } as any, { id: 'op2' } as any]), + ); + + await expectAsync(service.changePassword(TEST_PASSWORD)).toBeRejectedWithError( + /Cannot change password: 2 operation\(s\) have not been synced yet/, + ); + + // Should not proceed with password change + expect(mockCleanSlateService.createCleanSlate).not.toHaveBeenCalled(); + }); + + it('should proceed when there are no unsynced operations', async () => { + mockOpLogStore.getUnsynced.and.returnValue(Promise.resolve([])); + + await service.changePassword(TEST_PASSWORD); + + // Should proceed with password change + expect(mockCleanSlateService.createCleanSlate).toHaveBeenCalled(); + }); + it('should revert password config if upload fails', async () => { const originalConfig = { encryptKey: 'old-password', diff --git a/src/app/imex/sync/encryption-password-change.service.ts b/src/app/imex/sync/encryption-password-change.service.ts index 1cf0696a29..351e901323 100644 --- a/src/app/imex/sync/encryption-password-change.service.ts +++ b/src/app/imex/sync/encryption-password-change.service.ts @@ -8,6 +8,7 @@ import { DerivedKeyCacheService } from '../../op-log/encryption/derived-key-cach import { CleanSlateService } from '../../op-log/clean-slate/clean-slate.service'; import { OperationLogUploadService } from '../../op-log/sync/operation-log-upload.service'; import { SyncWrapperService } from './sync-wrapper.service'; +import { OperationLogStoreService } from '../../op-log/persistence/operation-log-store.service'; /** * Service for changing the encryption password for SuperSync. @@ -26,6 +27,7 @@ export class EncryptionPasswordChangeService { private _uploadService = inject(OperationLogUploadService); private _derivedKeyCache = inject(DerivedKeyCacheService); private _syncWrapper = inject(SyncWrapperService); + private _opLogStore = inject(OperationLogStoreService); /** * Changes the encryption password using the clean slate approach. @@ -58,6 +60,15 @@ export class EncryptionPasswordChangeService { throw new Error('Sync provider does not support operation sync'); } + // Check for unsynced operations before proceeding + const unsyncedOps = await this._opLogStore.getUnsynced(); + if (unsyncedOps.length > 0) { + throw new Error( + `Cannot change password: ${unsyncedOps.length} operation(s) have not been synced yet. ` + + 'Please wait for sync to complete or manually trigger a sync before changing the password.', + ); + } + // Run the entire password change with sync blocked to prevent race conditions. // This waits for any ongoing sync to complete, then blocks new syncs. await this._syncWrapper.runWithSyncBlocked(async () => {