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.
This commit is contained in:
Johannes Millan 2026-01-24 16:36:49 +01:00
parent 4ea30b183b
commit 842baf81bf
2 changed files with 40 additions and 0 deletions

View file

@ -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<OperationLogUploadService>;
let mockDerivedKeyCache: jasmine.SpyObj<DerivedKeyCacheService>;
let mockSyncWrapper: jasmine.SpyObj<SyncWrapperService>;
let mockOpLogStore: jasmine.SpyObj<OperationLogStoreService>;
let mockSyncProvider: jasmine.SpyObj<any>;
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',

View file

@ -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 () => {