mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
refactor(sync): simplify SuperSync client code (#6728)
- Unify duplicated HTTP methods in SuperSyncProvider into shared helpers - Remove 5 encryption DI injection tokens, use private properties for test spying - Remove DerivedKeyCacheService wrapper, use direct imports - Cache _getServerSeqKey() result in SuperSyncProvider - Split SyncProviderServiceInterface into SyncProviderBase + FileSyncProvider - Remove dead file-op stubs from SuperSync provider - Extract shared delete-and-reupload pattern into SnapshotUploadService - Extract helper methods from operation-log-sync.service.ts - Auto-invalidate WrappedProviderService cache on config changes - Fix: preserve auth credentials in encryption toggle revert paths - Fix: add config revert on disableEncryption failure ~720 lines net reduction across 26 files.
This commit is contained in:
parent
7c30916d28
commit
49bf056ee0
26 changed files with 802 additions and 1485 deletions
|
|
@ -3,22 +3,18 @@ import { EncryptionPasswordChangeService } from './encryption-password-change.se
|
|||
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
|
||||
import { CleanSlateService } from '../../op-log/clean-slate/clean-slate.service';
|
||||
import { OperationLogUploadService } from '../../op-log/sync/operation-log-upload.service';
|
||||
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';
|
||||
import { OpType } from '../../op-log/core/operation.types';
|
||||
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
|
||||
|
||||
describe('EncryptionPasswordChangeService', () => {
|
||||
let service: EncryptionPasswordChangeService;
|
||||
let mockProviderManager: jasmine.SpyObj<any>;
|
||||
let mockCleanSlateService: jasmine.SpyObj<CleanSlateService>;
|
||||
let mockUploadService: jasmine.SpyObj<OperationLogUploadService>;
|
||||
let mockDerivedKeyCache: jasmine.SpyObj<DerivedKeyCacheService>;
|
||||
let mockSyncWrapper: jasmine.SpyObj<SyncWrapperService>;
|
||||
let mockOpLogStore: jasmine.SpyObj<OperationLogStoreService>;
|
||||
let mockWrappedProviderService: jasmine.SpyObj<WrappedProviderService>;
|
||||
let mockSyncProvider: jasmine.SpyObj<any>;
|
||||
|
||||
const TEST_PASSWORD = 'new-secure-password-123';
|
||||
|
|
@ -65,8 +61,6 @@ describe('EncryptionPasswordChangeService', () => {
|
|||
}),
|
||||
);
|
||||
|
||||
mockDerivedKeyCache = jasmine.createSpyObj('DerivedKeyCacheService', ['clearCache']);
|
||||
|
||||
// Mock SyncWrapperService - runWithSyncBlocked should just execute the callback
|
||||
mockSyncWrapper = jasmine.createSpyObj('SyncWrapperService', ['runWithSyncBlocked']);
|
||||
mockSyncWrapper.runWithSyncBlocked.and.callFake(
|
||||
|
|
@ -91,20 +85,14 @@ describe('EncryptionPasswordChangeService', () => {
|
|||
}
|
||||
});
|
||||
|
||||
mockWrappedProviderService = jasmine.createSpyObj('WrappedProviderService', [
|
||||
'clearCache',
|
||||
]);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
EncryptionPasswordChangeService,
|
||||
{ provide: SyncProviderManager, useValue: mockProviderManager },
|
||||
{ provide: CleanSlateService, useValue: mockCleanSlateService },
|
||||
{ provide: OperationLogUploadService, useValue: mockUploadService },
|
||||
{ provide: DerivedKeyCacheService, useValue: mockDerivedKeyCache },
|
||||
{ provide: SyncWrapperService, useValue: mockSyncWrapper },
|
||||
{ provide: OperationLogStoreService, useValue: mockOpLogStore },
|
||||
{ provide: WrappedProviderService, useValue: mockWrappedProviderService },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(EncryptionPasswordChangeService);
|
||||
|
|
@ -129,8 +117,7 @@ describe('EncryptionPasswordChangeService', () => {
|
|||
}),
|
||||
);
|
||||
|
||||
// Should clear derived key cache
|
||||
expect(mockDerivedKeyCache.clearCache).toHaveBeenCalled();
|
||||
// clearSessionKeyCache() is called directly (module-level function, not spyable)
|
||||
|
||||
// Should upload with isCleanSlate flag
|
||||
expect(mockUploadService.uploadPendingOps).toHaveBeenCalledWith(mockSyncProvider, {
|
||||
|
|
@ -301,8 +288,7 @@ describe('EncryptionPasswordChangeService', () => {
|
|||
// Should NOT revert - only one call to setPrivateCfg
|
||||
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Should have cleared cache only once (on change, not on revert)
|
||||
expect(mockDerivedKeyCache.clearCache).toHaveBeenCalledTimes(1);
|
||||
// clearSessionKeyCache() is called directly (module-level function, not spyable)
|
||||
});
|
||||
|
||||
it('should throw error if no operations are uploaded', async () => {
|
||||
|
|
@ -380,13 +366,9 @@ describe('EncryptionPasswordChangeService', () => {
|
|||
callOrder.push('setProviderConfig');
|
||||
});
|
||||
|
||||
mockDerivedKeyCache.clearCache.and.callFake(() => {
|
||||
callOrder.push('clearDerivedKeyCache');
|
||||
});
|
||||
|
||||
mockWrappedProviderService.clearCache.and.callFake(() => {
|
||||
callOrder.push('clearWrappedProviderCache');
|
||||
});
|
||||
// clearSessionKeyCache() is called directly (module-level function, not spyable)
|
||||
// so we can't track its order, but it runs between setProviderConfig and uploadPendingOps
|
||||
// WrappedProviderService cache is now auto-invalidated via providerConfigChanged$
|
||||
|
||||
mockUploadService.uploadPendingOps.and.callFake(async () => {
|
||||
callOrder.push('uploadPendingOps');
|
||||
|
|
@ -405,8 +387,8 @@ describe('EncryptionPasswordChangeService', () => {
|
|||
'createCleanSlate',
|
||||
'getUnsynced(2)', // Verify SYNC_IMPORT was stored
|
||||
'setProviderConfig',
|
||||
'clearDerivedKeyCache',
|
||||
'clearWrappedProviderCache',
|
||||
// clearSessionKeyCache() runs here (not trackable - module-level function)
|
||||
// WrappedProviderService cache is auto-invalidated via providerConfigChanged$
|
||||
'uploadPendingOps',
|
||||
]);
|
||||
});
|
||||
|
|
@ -495,7 +477,7 @@ describe('EncryptionPasswordChangeService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should clear caches once on upload failure (no revert)', async () => {
|
||||
it('should not revert config on upload failure', async () => {
|
||||
// When upload fails, we keep the new password config for retry.
|
||||
// User must retry with the SAME password to complete the upload.
|
||||
// This prevents inconsistent state where SYNC_IMPORT would be
|
||||
|
|
@ -524,10 +506,10 @@ describe('EncryptionPasswordChangeService', () => {
|
|||
|
||||
await expectAsync(service.changePassword(TEST_PASSWORD)).toBeRejected();
|
||||
|
||||
// Should have cleared caches only once (no revert):
|
||||
// After setting new password (before upload attempt)
|
||||
expect(mockDerivedKeyCache.clearCache).toHaveBeenCalledTimes(1);
|
||||
expect(mockWrappedProviderService.clearCache).toHaveBeenCalledTimes(1);
|
||||
// clearSessionKeyCache() is called directly (module-level function, not spyable)
|
||||
// WrappedProviderService cache is auto-invalidated via providerConfigChanged$
|
||||
// Config should only be set once (no revert)
|
||||
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw error if SYNC_IMPORT was not stored after clean slate', async () => {
|
||||
|
|
|
|||
|
|
@ -4,13 +4,12 @@ import { isOperationSyncCapable } from '../../op-log/sync/operation-sync.util';
|
|||
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
||||
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { DerivedKeyCacheService } from '../../op-log/encryption/derived-key-cache.service';
|
||||
import { clearSessionKeyCache } from '../../op-log/encryption/encryption';
|
||||
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';
|
||||
import { isFullStateOpType } from '../../op-log/core/operation.types';
|
||||
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
|
||||
|
||||
/**
|
||||
* Service for changing the encryption password for SuperSync.
|
||||
|
|
@ -27,10 +26,8 @@ export class EncryptionPasswordChangeService {
|
|||
private _providerManager = inject(SyncProviderManager);
|
||||
private _cleanSlateService = inject(CleanSlateService);
|
||||
private _uploadService = inject(OperationLogUploadService);
|
||||
private _derivedKeyCache = inject(DerivedKeyCacheService);
|
||||
private _syncWrapper = inject(SyncWrapperService);
|
||||
private _opLogStore = inject(OperationLogStoreService);
|
||||
private _wrappedProviderService = inject(WrappedProviderService);
|
||||
|
||||
/**
|
||||
* Changes the encryption password using the clean slate approach.
|
||||
|
|
@ -125,9 +122,7 @@ export class EncryptionPasswordChangeService {
|
|||
} as SuperSyncPrivateCfg);
|
||||
|
||||
// Clear cached encryption keys to force re-derivation with new password
|
||||
this._derivedKeyCache.clearCache();
|
||||
// Clear cached adapters to ensure new encryption settings take effect
|
||||
this._wrappedProviderService.clearCache();
|
||||
clearSessionKeyCache();
|
||||
|
||||
// STEP 4: Upload the SYNC_IMPORT with isCleanSlate=true flag
|
||||
// The server will delete all existing data before accepting the operation
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ import {
|
|||
} from '../../op-log/util/client-id.provider';
|
||||
import { FileBasedSyncAdapterService } from '../../op-log/sync-providers/file-based/file-based-sync-adapter.service';
|
||||
import { GlobalConfigService } from '../../features/config/global-config.service';
|
||||
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
|
||||
import { DerivedKeyCacheService } from '../../op-log/encryption/derived-key-cache.service';
|
||||
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
||||
import { SyncProviderServiceInterface } from '../../op-log/sync-providers/provider.interface';
|
||||
|
||||
|
|
@ -22,8 +20,6 @@ describe('FileBasedEncryptionService', () => {
|
|||
let mockClientIdProvider: jasmine.SpyObj<ClientIdProvider>;
|
||||
let mockFileBasedAdapter: jasmine.SpyObj<FileBasedSyncAdapterService>;
|
||||
let mockGlobalConfigService: jasmine.SpyObj<GlobalConfigService>;
|
||||
let mockWrappedProviderService: jasmine.SpyObj<WrappedProviderService>;
|
||||
let mockDerivedKeyCache: jasmine.SpyObj<DerivedKeyCacheService>;
|
||||
let mockProvider: SyncProviderServiceInterface<SyncProviderId>;
|
||||
let mockAdapter: {
|
||||
uploadSnapshot: jasmine.Spy;
|
||||
|
|
@ -97,12 +93,6 @@ describe('FileBasedEncryptionService', () => {
|
|||
'updateSection',
|
||||
]);
|
||||
|
||||
mockWrappedProviderService = jasmine.createSpyObj('WrappedProviderService', [
|
||||
'clearCache',
|
||||
]);
|
||||
|
||||
mockDerivedKeyCache = jasmine.createSpyObj('DerivedKeyCacheService', ['clearCache']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
FileBasedEncryptionService,
|
||||
|
|
@ -112,8 +102,6 @@ describe('FileBasedEncryptionService', () => {
|
|||
{ provide: CLIENT_ID_PROVIDER, useValue: mockClientIdProvider },
|
||||
{ provide: FileBasedSyncAdapterService, useValue: mockFileBasedAdapter },
|
||||
{ provide: GlobalConfigService, useValue: mockGlobalConfigService },
|
||||
{ provide: WrappedProviderService, useValue: mockWrappedProviderService },
|
||||
{ provide: DerivedKeyCacheService, useValue: mockDerivedKeyCache },
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -203,11 +191,11 @@ describe('FileBasedEncryptionService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should clear derived key cache and wrapped provider cache', async () => {
|
||||
it('should clear derived key cache', async () => {
|
||||
await service.enableEncryption('my-password');
|
||||
|
||||
expect(mockDerivedKeyCache.clearCache).toHaveBeenCalled();
|
||||
expect(mockWrappedProviderService.clearCache).toHaveBeenCalled();
|
||||
// clearSessionKeyCache() is called directly (module-level function, not spyable)
|
||||
// WrappedProviderService cache is now auto-invalidated via providerConfigChanged$
|
||||
});
|
||||
|
||||
it('should set lastServerSeq when returned from adapter', async () => {
|
||||
|
|
@ -300,11 +288,11 @@ describe('FileBasedEncryptionService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should clear derived key cache and wrapped provider cache', async () => {
|
||||
it('should clear derived key cache', async () => {
|
||||
await service.changePassword('new-password');
|
||||
|
||||
expect(mockDerivedKeyCache.clearCache).toHaveBeenCalled();
|
||||
expect(mockWrappedProviderService.clearCache).toHaveBeenCalled();
|
||||
// clearSessionKeyCache() is called directly (module-level function, not spyable)
|
||||
// WrappedProviderService cache is now auto-invalidated via providerConfigChanged$
|
||||
});
|
||||
|
||||
it('should update global config with isEncryptionEnabled: true', async () => {
|
||||
|
|
@ -386,8 +374,8 @@ describe('FileBasedEncryptionService', () => {
|
|||
it('should clear caches after successful upload', async () => {
|
||||
await service.disableEncryption();
|
||||
|
||||
expect(mockDerivedKeyCache.clearCache).toHaveBeenCalled();
|
||||
expect(mockWrappedProviderService.clearCache).toHaveBeenCalled();
|
||||
// clearSessionKeyCache() is called directly (module-level function, not spyable)
|
||||
// WrappedProviderService cache is now auto-invalidated via providerConfigChanged$
|
||||
});
|
||||
|
||||
it('should NOT update config on upload failure', async () => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { inject, Injectable } from '@angular/core';
|
|||
import { SyncLog } from '../../core/log';
|
||||
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
|
||||
import { isFileBasedProvider } from '../../op-log/sync/operation-sync.util';
|
||||
import { FileSyncProvider } from '../../op-log/sync-providers/provider.interface';
|
||||
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
||||
import { StateSnapshotService } from '../../op-log/backup/state-snapshot.service';
|
||||
import { VectorClockService } from '../../op-log/sync/vector-clock.service';
|
||||
import {
|
||||
|
|
@ -12,8 +14,7 @@ import { FileBasedSyncAdapterService } from '../../op-log/sync-providers/file-ba
|
|||
import { CURRENT_SCHEMA_VERSION } from '../../op-log/persistence/schema-migration.service';
|
||||
import { uuidv7 } from '../../util/uuid-v7';
|
||||
import { GlobalConfigService } from '../../features/config/global-config.service';
|
||||
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
|
||||
import { DerivedKeyCacheService } from '../../op-log/encryption/derived-key-cache.service';
|
||||
import { clearSessionKeyCache } from '../../op-log/encryption/encryption';
|
||||
|
||||
const LOG_PREFIX = 'FileBasedEncryptionService';
|
||||
|
||||
|
|
@ -27,8 +28,6 @@ export class FileBasedEncryptionService {
|
|||
private _clientIdProvider: ClientIdProvider = inject(CLIENT_ID_PROVIDER);
|
||||
private _fileBasedAdapter = inject(FileBasedSyncAdapterService);
|
||||
private _globalConfigService = inject(GlobalConfigService);
|
||||
private _wrappedProviderService = inject(WrappedProviderService);
|
||||
private _derivedKeyCache = inject(DerivedKeyCacheService);
|
||||
|
||||
async enableEncryption(encryptKey: string): Promise<void> {
|
||||
await this._applyEncryption(encryptKey, 'enable');
|
||||
|
|
@ -66,7 +65,10 @@ export class FileBasedEncryptionService {
|
|||
);
|
||||
}
|
||||
|
||||
if (!(await provider.isReady())) {
|
||||
// After isFileBasedProvider check, we know this is a file-based provider
|
||||
const fileProvider = provider as FileSyncProvider<SyncProviderId>;
|
||||
|
||||
if (!(await fileProvider.isReady())) {
|
||||
throw new Error('Sync provider is not ready. Please configure sync first.');
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +80,7 @@ export class FileBasedEncryptionService {
|
|||
throw new Error('Client ID not available');
|
||||
}
|
||||
|
||||
const existingCfg = await provider.privateCfg.load();
|
||||
const existingCfg = await fileProvider.privateCfg.load();
|
||||
|
||||
const baseCfg = this._providerManager.getEncryptAndCompressCfg();
|
||||
const adapterCfg = {
|
||||
|
|
@ -87,7 +89,7 @@ export class FileBasedEncryptionService {
|
|||
};
|
||||
|
||||
const adapter = this._fileBasedAdapter.createAdapter(
|
||||
provider,
|
||||
fileProvider,
|
||||
adapterCfg,
|
||||
isDisable ? undefined : encryptKey,
|
||||
);
|
||||
|
|
@ -137,8 +139,7 @@ export class FileBasedEncryptionService {
|
|||
throw cfgError;
|
||||
}
|
||||
|
||||
this._derivedKeyCache.clearCache();
|
||||
this._wrappedProviderService.clearCache();
|
||||
clearSessionKeyCache();
|
||||
|
||||
if (result.serverSeq !== undefined) {
|
||||
await adapter.setLastServerSeq(result.serverSeq);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { ImportEncryptionHandlerService } from './import-encryption-handler.service';
|
||||
import { SnapshotUploadData, SnapshotUploadService } from './snapshot-upload.service';
|
||||
import { SnapshotUploadService } from './snapshot-upload.service';
|
||||
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
|
||||
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
|
||||
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
||||
import {
|
||||
OperationSyncCapable,
|
||||
|
|
@ -15,7 +14,6 @@ describe('ImportEncryptionHandlerService', () => {
|
|||
let service: ImportEncryptionHandlerService;
|
||||
let mockSnapshotUploadService: jasmine.SpyObj<SnapshotUploadService>;
|
||||
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
|
||||
let mockEncryptionService: jasmine.SpyObj<OperationEncryptionService>;
|
||||
let mockSyncProvider: jasmine.SpyObj<
|
||||
SyncProviderServiceInterface<SyncProviderId> & OperationSyncCapable
|
||||
>;
|
||||
|
|
@ -58,34 +56,19 @@ describe('ImportEncryptionHandlerService', () => {
|
|||
mockProviderManager.setProviderConfig.and.resolveTo();
|
||||
|
||||
mockSnapshotUploadService = jasmine.createSpyObj('SnapshotUploadService', [
|
||||
'gatherSnapshotData',
|
||||
'uploadSnapshot',
|
||||
'updateLastServerSeq',
|
||||
'deleteAndReuploadWithNewEncryption',
|
||||
]);
|
||||
mockSnapshotUploadService.gatherSnapshotData.and.resolveTo({
|
||||
syncProvider: mockSyncProvider,
|
||||
existingCfg: mockExistingCfg,
|
||||
state: { task: [] },
|
||||
vectorClock: { testClient1: 1 },
|
||||
clientId: 'testClient1',
|
||||
} as unknown as SnapshotUploadData);
|
||||
mockSnapshotUploadService.uploadSnapshot.and.resolveTo({
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption.and.resolveTo({
|
||||
accepted: true,
|
||||
serverSeq: 1,
|
||||
existingCfg: mockExistingCfg,
|
||||
});
|
||||
mockSnapshotUploadService.updateLastServerSeq.and.resolveTo();
|
||||
|
||||
mockEncryptionService = jasmine.createSpyObj('OperationEncryptionService', [
|
||||
'encryptPayload',
|
||||
]);
|
||||
mockEncryptionService.encryptPayload.and.resolveTo('encrypted-data');
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ImportEncryptionHandlerService,
|
||||
{ provide: SyncProviderManager, useValue: mockProviderManager },
|
||||
{ provide: SnapshotUploadService, useValue: mockSnapshotUploadService },
|
||||
{ provide: OperationEncryptionService, useValue: mockEncryptionService },
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -152,75 +135,36 @@ describe('ImportEncryptionHandlerService', () => {
|
|||
});
|
||||
|
||||
describe('handleEncryptionStateChange', () => {
|
||||
it('should return error result when provider validation fails', async () => {
|
||||
mockSnapshotUploadService.gatherSnapshotData.and.rejectWith(
|
||||
new Error('No active provider'),
|
||||
);
|
||||
|
||||
const result = await service.handleEncryptionStateChange(
|
||||
createMockImportedData(),
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.encryptionStateChanged).toBeFalse();
|
||||
expect(result.serverDataDeleted).toBeFalse();
|
||||
expect(result.error).toContain('No active provider');
|
||||
});
|
||||
|
||||
it('should delete server data before uploading', async () => {
|
||||
await service.handleEncryptionStateChange(
|
||||
createMockImportedData(),
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockSyncProvider.deleteAllData).toHaveBeenCalledTimes(1);
|
||||
expect(mockSyncProvider.deleteAllData).toHaveBeenCalledBefore(
|
||||
mockSnapshotUploadService.uploadSnapshot,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update provider config before uploading', async () => {
|
||||
it('should delegate to deleteAndReuploadWithNewEncryption with correct params when enabling', async () => {
|
||||
await service.handleEncryptionStateChange(
|
||||
createMockImportedData(),
|
||||
'new-key',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
|
||||
SyncProviderId.SuperSync,
|
||||
jasmine.objectContaining({
|
||||
encryptKey: 'new-key',
|
||||
isEncryptionEnabled: true,
|
||||
}),
|
||||
);
|
||||
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledBefore(
|
||||
mockSnapshotUploadService.uploadSnapshot,
|
||||
);
|
||||
expect(
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption,
|
||||
).toHaveBeenCalledWith({
|
||||
encryptKey: 'new-key',
|
||||
isEncryptionEnabled: true,
|
||||
logPrefix: 'ImportEncryptionHandlerService',
|
||||
});
|
||||
});
|
||||
|
||||
it('should encrypt payload when encryption is enabled', async () => {
|
||||
await service.handleEncryptionStateChange(
|
||||
createMockImportedData(),
|
||||
'new-key',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(mockEncryptionService.encryptPayload).toHaveBeenCalledWith(
|
||||
{ task: [] },
|
||||
'new-key',
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT encrypt payload when encryption is disabled', async () => {
|
||||
it('should delegate to deleteAndReuploadWithNewEncryption with correct params when disabling', async () => {
|
||||
await service.handleEncryptionStateChange(
|
||||
createMockImportedData(),
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockEncryptionService.encryptPayload).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption,
|
||||
).toHaveBeenCalledWith({
|
||||
encryptKey: undefined,
|
||||
isEncryptionEnabled: false,
|
||||
logPrefix: 'ImportEncryptionHandlerService',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return success result on successful upload', async () => {
|
||||
|
|
@ -236,8 +180,10 @@ describe('ImportEncryptionHandlerService', () => {
|
|||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return error result when upload fails', async () => {
|
||||
mockSnapshotUploadService.uploadSnapshot.and.rejectWith(new Error('Network error'));
|
||||
it('should return error result when deleteAndReuploadWithNewEncryption fails', async () => {
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption.and.rejectWith(
|
||||
new Error('Network error'),
|
||||
);
|
||||
|
||||
const result = await service.handleEncryptionStateChange(
|
||||
createMockImportedData(),
|
||||
|
|
@ -250,18 +196,20 @@ describe('ImportEncryptionHandlerService', () => {
|
|||
expect(result.error).toContain('Network error');
|
||||
});
|
||||
|
||||
it('should update lastServerSeq after successful upload', async () => {
|
||||
await service.handleEncryptionStateChange(
|
||||
it('should return error result when provider validation fails', async () => {
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption.and.rejectWith(
|
||||
new Error('No active sync provider. Please enable sync first.'),
|
||||
);
|
||||
|
||||
const result = await service.handleEncryptionStateChange(
|
||||
createMockImportedData(),
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockSnapshotUploadService.updateLastServerSeq).toHaveBeenCalledWith(
|
||||
mockSyncProvider as any,
|
||||
1,
|
||||
'ImportEncryptionHandlerService',
|
||||
);
|
||||
expect(result.encryptionStateChanged).toBeTrue();
|
||||
expect(result.serverDataDeleted).toBeFalse();
|
||||
expect(result.error).toContain('No active sync provider');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -273,7 +221,9 @@ describe('ImportEncryptionHandlerService', () => {
|
|||
const result = await service.handleImportEncryptionIfNeeded(importedData);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockSyncProvider.deleteAllData).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle encryption state change when detected', async () => {
|
||||
|
|
@ -284,7 +234,9 @@ describe('ImportEncryptionHandlerService', () => {
|
|||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.encryptionStateChanged).toBeTrue();
|
||||
expect(mockSyncProvider.deleteAllData).toHaveBeenCalled();
|
||||
expect(
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption,
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass correct encryption key from imported data', async () => {
|
||||
|
|
@ -292,13 +244,13 @@ describe('ImportEncryptionHandlerService', () => {
|
|||
|
||||
await service.handleImportEncryptionIfNeeded(importedData);
|
||||
|
||||
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
|
||||
SyncProviderId.SuperSync,
|
||||
jasmine.objectContaining({
|
||||
encryptKey: 'imported-encryption-key',
|
||||
isEncryptionEnabled: true,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption,
|
||||
).toHaveBeenCalledWith({
|
||||
encryptKey: 'imported-encryption-key',
|
||||
isEncryptionEnabled: true,
|
||||
logPrefix: 'ImportEncryptionHandlerService',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@ import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
|||
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { AppDataComplete } from '../../op-log/model/model-config';
|
||||
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
|
||||
import { SnapshotUploadService } from './snapshot-upload.service';
|
||||
import { isCryptoSubtleAvailable } from '../../op-log/encryption/encryption';
|
||||
|
||||
export interface EncryptionStateChangeResult {
|
||||
encryptionStateChanged: boolean;
|
||||
|
|
@ -26,17 +24,13 @@ const LOG_PREFIX = 'ImportEncryptionHandlerService';
|
|||
* 2. The current state is uploaded as a fresh snapshot with correct encryption
|
||||
* 3. The sync provider config is updated to match the imported settings
|
||||
*
|
||||
* This is necessary because:
|
||||
* - Encrypted and unencrypted operations cannot coexist on the server
|
||||
* - A fresh snapshot ensures all clients get a consistent state
|
||||
* - The import represents a "tabula rasa" for the sync state
|
||||
* Delegates the delete-and-reupload mechanics to SnapshotUploadService.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ImportEncryptionHandlerService {
|
||||
private _providerManager = inject(SyncProviderManager);
|
||||
private _encryptionService = inject(OperationEncryptionService);
|
||||
private _snapshotUploadService = inject(SnapshotUploadService);
|
||||
|
||||
/**
|
||||
|
|
@ -114,89 +108,12 @@ export class ImportEncryptionHandlerService {
|
|||
hasKey: !!newEncryptKey,
|
||||
});
|
||||
|
||||
// Validate provider - use try/catch since this service returns results instead of throwing
|
||||
let snapshotData;
|
||||
try {
|
||||
snapshotData = await this._snapshotUploadService.gatherSnapshotData(LOG_PREFIX);
|
||||
} catch (validationError) {
|
||||
const errorMessage =
|
||||
validationError instanceof Error
|
||||
? validationError.message
|
||||
: 'Provider validation failed';
|
||||
return {
|
||||
encryptionStateChanged: false,
|
||||
serverDataDeleted: false,
|
||||
snapshotUploaded: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
const { syncProvider, existingCfg, state, vectorClock, clientId } = snapshotData;
|
||||
|
||||
// CRITICAL: Check crypto availability BEFORE deleting server data
|
||||
// to prevent data loss if encryption will fail (only needed when enabling encryption)
|
||||
if (isEncryptionEnabled && !isCryptoSubtleAvailable()) {
|
||||
return {
|
||||
encryptionStateChanged: false,
|
||||
serverDataDeleted: false,
|
||||
snapshotUploaded: false,
|
||||
error:
|
||||
'Cannot enable encryption: WebCrypto API is not available. ' +
|
||||
'Encryption requires a secure context (HTTPS). ' +
|
||||
'On Android, encryption is not supported.',
|
||||
};
|
||||
}
|
||||
|
||||
let serverDataDeleted = false;
|
||||
try {
|
||||
// 1. Delete all server data (encrypted ops can't mix with unencrypted)
|
||||
SyncLog.normal(`${LOG_PREFIX}: Deleting server data...`);
|
||||
await syncProvider.deleteAllData();
|
||||
serverDataDeleted = true;
|
||||
|
||||
// 2. Update sync provider config with new encryption settings BEFORE upload
|
||||
// IMPORTANT: Use providerManager.setProviderConfig() instead of direct setPrivateCfg()
|
||||
// to ensure the currentProviderPrivateCfg$ observable is updated, which is needed
|
||||
// for the settings form to correctly show isEncryptionEnabled state.
|
||||
SyncLog.normal(`${LOG_PREFIX}: Updating provider config...`);
|
||||
await this._providerManager.setProviderConfig(SyncProviderId.SuperSync, {
|
||||
...existingCfg,
|
||||
await this._snapshotUploadService.deleteAndReuploadWithNewEncryption({
|
||||
encryptKey: isEncryptionEnabled ? newEncryptKey : undefined,
|
||||
isEncryptionEnabled,
|
||||
} as SuperSyncPrivateCfg);
|
||||
|
||||
// 3. Prepare snapshot payload (encrypt if needed)
|
||||
SyncLog.normal(`${LOG_PREFIX}: Uploading fresh snapshot...`);
|
||||
let snapshotPayload: unknown = state;
|
||||
|
||||
// If encryption is enabled, manually encrypt the snapshot
|
||||
// (unlike other services, import handler encrypts explicitly)
|
||||
if (isEncryptionEnabled && newEncryptKey) {
|
||||
snapshotPayload = await this._encryptionService.encryptPayload(
|
||||
state,
|
||||
newEncryptKey,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Upload snapshot
|
||||
const result = await this._snapshotUploadService.uploadSnapshot(
|
||||
syncProvider,
|
||||
snapshotPayload,
|
||||
clientId,
|
||||
vectorClock,
|
||||
isEncryptionEnabled && !!newEncryptKey,
|
||||
);
|
||||
|
||||
if (!result.accepted) {
|
||||
throw new Error(`Snapshot upload failed: ${result.error}`);
|
||||
}
|
||||
|
||||
// 5. Update lastServerSeq
|
||||
await this._snapshotUploadService.updateLastServerSeq(
|
||||
syncProvider,
|
||||
result.serverSeq,
|
||||
LOG_PREFIX,
|
||||
);
|
||||
logPrefix: LOG_PREFIX,
|
||||
});
|
||||
|
||||
SyncLog.normal(`${LOG_PREFIX}: Encryption state change handled successfully!`);
|
||||
|
||||
|
|
@ -214,7 +131,7 @@ export class ImportEncryptionHandlerService {
|
|||
|
||||
return {
|
||||
encryptionStateChanged: true,
|
||||
serverDataDeleted,
|
||||
serverDataDeleted: false,
|
||||
snapshotUploaded: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {
|
|||
OperationSyncCapable,
|
||||
SyncProviderServiceInterface,
|
||||
} from '../../op-log/sync-providers/provider.interface';
|
||||
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
|
||||
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
|
||||
|
||||
describe('SnapshotUploadService', () => {
|
||||
let service: SnapshotUploadService;
|
||||
|
|
@ -16,10 +18,18 @@ describe('SnapshotUploadService', () => {
|
|||
let mockStateSnapshotService: jasmine.SpyObj<StateSnapshotService>;
|
||||
let mockVectorClockService: jasmine.SpyObj<VectorClockService>;
|
||||
let mockClientIdProvider: { loadClientId: jasmine.Spy };
|
||||
let mockEncryptionService: jasmine.SpyObj<OperationEncryptionService>;
|
||||
let mockSyncProvider: jasmine.SpyObj<
|
||||
SyncProviderServiceInterface<SyncProviderId> & OperationSyncCapable
|
||||
>;
|
||||
|
||||
const mockExistingCfg: SuperSyncPrivateCfg = {
|
||||
baseUrl: 'https://test.example.com',
|
||||
accessToken: 'test-token',
|
||||
isEncryptionEnabled: false,
|
||||
encryptKey: undefined,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockSyncProvider = jasmine.createSpyObj('SyncProvider', [
|
||||
'uploadSnapshot',
|
||||
|
|
@ -30,15 +40,23 @@ describe('SnapshotUploadService', () => {
|
|||
mockSyncProvider.id = SyncProviderId.SuperSync;
|
||||
mockSyncProvider.isReady = jasmine.createSpy('isReady').and.resolveTo(true);
|
||||
mockSyncProvider.privateCfg = {
|
||||
load: jasmine.createSpy('load').and.resolveTo(null),
|
||||
load: jasmine.createSpy('load').and.resolveTo(mockExistingCfg),
|
||||
} as any;
|
||||
// Mark as operation-sync capable (isOperationSyncCapable checks for this property)
|
||||
(mockSyncProvider as any).supportsOperationSync = true;
|
||||
mockSyncProvider.deleteAllData.and.resolveTo({ success: true });
|
||||
mockSyncProvider.uploadSnapshot.and.resolveTo({
|
||||
accepted: true,
|
||||
serverSeq: 42,
|
||||
});
|
||||
mockSyncProvider.setLastServerSeq.and.resolveTo(undefined);
|
||||
|
||||
mockProviderManager = jasmine.createSpyObj('SyncProviderManager', [
|
||||
'getActiveProvider',
|
||||
'setProviderConfig',
|
||||
]);
|
||||
mockProviderManager.getActiveProvider.and.returnValue(mockSyncProvider);
|
||||
mockProviderManager.setProviderConfig.and.resolveTo();
|
||||
|
||||
mockStateSnapshotService = jasmine.createSpyObj('StateSnapshotService', [
|
||||
'getStateSnapshotAsync',
|
||||
|
|
@ -54,6 +72,11 @@ describe('SnapshotUploadService', () => {
|
|||
loadClientId: jasmine.createSpy('loadClientId').and.resolveTo('test-client-id'),
|
||||
};
|
||||
|
||||
mockEncryptionService = jasmine.createSpyObj('OperationEncryptionService', [
|
||||
'encryptPayload',
|
||||
]);
|
||||
mockEncryptionService.encryptPayload.and.resolveTo('encrypted-state-data');
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
SnapshotUploadService,
|
||||
|
|
@ -61,6 +84,7 @@ describe('SnapshotUploadService', () => {
|
|||
{ provide: StateSnapshotService, useValue: mockStateSnapshotService },
|
||||
{ provide: VectorClockService, useValue: mockVectorClockService },
|
||||
{ provide: CLIENT_ID_PROVIDER, useValue: mockClientIdProvider },
|
||||
{ provide: OperationEncryptionService, useValue: mockEncryptionService },
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -176,4 +200,155 @@ describe('SnapshotUploadService', () => {
|
|||
expect(mockSyncProvider.setLastServerSeq).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAndReuploadWithNewEncryption', () => {
|
||||
it('should gather data, delete, update config, and upload when disabling encryption', async () => {
|
||||
const mockState = { task: [] };
|
||||
mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo(mockState as any);
|
||||
mockVectorClockService.getCurrentVectorClock.and.resolveTo({ c1: 1 });
|
||||
|
||||
const result = await service.deleteAndReuploadWithNewEncryption({
|
||||
encryptKey: undefined,
|
||||
isEncryptionEnabled: false,
|
||||
logPrefix: 'TestPrefix',
|
||||
});
|
||||
|
||||
expect(result.accepted).toBeTrue();
|
||||
expect(result.serverSeq).toBe(42);
|
||||
expect(mockSyncProvider.deleteAllData).toHaveBeenCalled();
|
||||
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
|
||||
SyncProviderId.SuperSync,
|
||||
jasmine.objectContaining({
|
||||
isEncryptionEnabled: false,
|
||||
encryptKey: undefined,
|
||||
}),
|
||||
);
|
||||
expect(mockSyncProvider.uploadSnapshot).toHaveBeenCalled();
|
||||
expect(mockSyncProvider.setLastServerSeq).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('should encrypt payload when enabling encryption', async () => {
|
||||
const mockState = { task: [] };
|
||||
mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo(mockState as any);
|
||||
|
||||
await service.deleteAndReuploadWithNewEncryption({
|
||||
encryptKey: 'my-key',
|
||||
isEncryptionEnabled: true,
|
||||
logPrefix: 'TestPrefix',
|
||||
});
|
||||
|
||||
expect(mockEncryptionService.encryptPayload).toHaveBeenCalledWith(
|
||||
mockState,
|
||||
'my-key',
|
||||
);
|
||||
// uploadSnapshot on the provider receives: payload, clientId, reason, vectorClock, schemaVersion, isEncrypted, requestId
|
||||
expect(mockSyncProvider.uploadSnapshot).toHaveBeenCalledWith(
|
||||
'encrypted-state-data',
|
||||
jasmine.anything(),
|
||||
jasmine.anything(),
|
||||
jasmine.anything(),
|
||||
jasmine.anything(),
|
||||
true,
|
||||
jasmine.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT encrypt payload when disabling encryption', async () => {
|
||||
await service.deleteAndReuploadWithNewEncryption({
|
||||
encryptKey: undefined,
|
||||
isEncryptionEnabled: false,
|
||||
logPrefix: 'TestPrefix',
|
||||
});
|
||||
|
||||
expect(mockEncryptionService.encryptPayload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update provider config with new encryption settings', async () => {
|
||||
await service.deleteAndReuploadWithNewEncryption({
|
||||
encryptKey: 'new-key',
|
||||
isEncryptionEnabled: true,
|
||||
logPrefix: 'TestPrefix',
|
||||
});
|
||||
|
||||
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
|
||||
SyncProviderId.SuperSync,
|
||||
jasmine.objectContaining({
|
||||
encryptKey: 'new-key',
|
||||
isEncryptionEnabled: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return existingCfg in result', async () => {
|
||||
const result = await service.deleteAndReuploadWithNewEncryption({
|
||||
encryptKey: undefined,
|
||||
isEncryptionEnabled: false,
|
||||
logPrefix: 'TestPrefix',
|
||||
});
|
||||
|
||||
expect(result.existingCfg).toEqual(mockExistingCfg);
|
||||
});
|
||||
|
||||
it('should throw when upload is rejected', async () => {
|
||||
mockSyncProvider.uploadSnapshot.and.resolveTo({
|
||||
accepted: false,
|
||||
error: 'Server rejected',
|
||||
});
|
||||
|
||||
await expectAsync(
|
||||
service.deleteAndReuploadWithNewEncryption({
|
||||
encryptKey: undefined,
|
||||
isEncryptionEnabled: false,
|
||||
logPrefix: 'TestPrefix',
|
||||
}),
|
||||
).toBeRejectedWithError(/Snapshot upload failed/);
|
||||
});
|
||||
|
||||
it('should execute steps in correct order', async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
mockStateSnapshotService.getStateSnapshotAsync.and.callFake(async () => {
|
||||
callOrder.push('getStateSnapshotAsync');
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
mockEncryptionService.encryptPayload.and.callFake(async () => {
|
||||
callOrder.push('encryptPayload');
|
||||
return 'encrypted';
|
||||
});
|
||||
|
||||
mockSyncProvider.deleteAllData.and.callFake(async () => {
|
||||
callOrder.push('deleteAllData');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
mockProviderManager.setProviderConfig.and.callFake(async () => {
|
||||
callOrder.push('setProviderConfig');
|
||||
});
|
||||
|
||||
mockSyncProvider.uploadSnapshot.and.callFake(async () => {
|
||||
callOrder.push('uploadSnapshot');
|
||||
return { accepted: true, serverSeq: 42 };
|
||||
});
|
||||
|
||||
mockSyncProvider.setLastServerSeq.and.callFake(async () => {
|
||||
callOrder.push('setLastServerSeq');
|
||||
});
|
||||
|
||||
await service.deleteAndReuploadWithNewEncryption({
|
||||
encryptKey: 'key',
|
||||
isEncryptionEnabled: true,
|
||||
logPrefix: 'TestPrefix',
|
||||
});
|
||||
|
||||
expect(callOrder).toEqual([
|
||||
'getStateSnapshotAsync',
|
||||
'encryptPayload',
|
||||
'deleteAllData',
|
||||
'setProviderConfig',
|
||||
'uploadSnapshot',
|
||||
'setLastServerSeq',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,15 +17,18 @@ import { SyncLog } from '../../core/log';
|
|||
import { uuidv7 } from '../../util/uuid-v7';
|
||||
import {
|
||||
OperationSyncCapable,
|
||||
SyncProviderServiceInterface,
|
||||
SyncProviderBase,
|
||||
} from '../../op-log/sync-providers/provider.interface';
|
||||
import { VectorClock } from '../../core/util/vector-clock';
|
||||
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
|
||||
import { isCryptoSubtleAvailable } from '../../op-log/encryption/encryption';
|
||||
import { WebCryptoNotAvailableError } from '../../op-log/core/errors/sync-errors';
|
||||
|
||||
/**
|
||||
* Data gathered for a snapshot upload operation.
|
||||
*/
|
||||
export interface SnapshotUploadData {
|
||||
syncProvider: SyncProviderServiceInterface<SyncProviderId> & OperationSyncCapable;
|
||||
syncProvider: SyncProviderBase<SyncProviderId> & OperationSyncCapable;
|
||||
existingCfg: SuperSyncPrivateCfg | null;
|
||||
state: AppStateSnapshot;
|
||||
vectorClock: VectorClock;
|
||||
|
|
@ -64,13 +67,14 @@ export class SnapshotUploadService {
|
|||
private _stateSnapshotService = inject(StateSnapshotService);
|
||||
private _vectorClockService = inject(VectorClockService);
|
||||
private _clientIdProvider: ClientIdProvider = inject(CLIENT_ID_PROVIDER);
|
||||
private _encryptionService = inject(OperationEncryptionService);
|
||||
|
||||
/**
|
||||
* Validates that the active provider is SuperSync and operation-sync capable.
|
||||
*
|
||||
* @throws Error if no active provider, not SuperSync, or not operation-sync capable
|
||||
*/
|
||||
getValidatedSuperSyncProvider(): SyncProviderServiceInterface<SyncProviderId> &
|
||||
getValidatedSuperSyncProvider(): SyncProviderBase<SyncProviderId> &
|
||||
OperationSyncCapable {
|
||||
const syncProvider = this._providerManager.getActiveProvider();
|
||||
|
||||
|
|
@ -88,8 +92,7 @@ export class SnapshotUploadService {
|
|||
throw new Error('Sync provider does not support operation sync');
|
||||
}
|
||||
|
||||
return syncProvider as SyncProviderServiceInterface<SyncProviderId> &
|
||||
OperationSyncCapable;
|
||||
return syncProvider as SyncProviderBase<SyncProviderId> & OperationSyncCapable;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -145,7 +148,7 @@ export class SnapshotUploadService {
|
|||
* @param isPayloadEncrypted - Whether the payload is encrypted
|
||||
*/
|
||||
async uploadSnapshot(
|
||||
syncProvider: SyncProviderServiceInterface<SyncProviderId> & OperationSyncCapable,
|
||||
syncProvider: SyncProviderBase<SyncProviderId> & OperationSyncCapable,
|
||||
payload: unknown,
|
||||
clientId: string,
|
||||
vectorClock: VectorClock,
|
||||
|
|
@ -176,7 +179,7 @@ export class SnapshotUploadService {
|
|||
* @param logPrefix - Optional prefix for log messages
|
||||
*/
|
||||
async updateLastServerSeq(
|
||||
syncProvider: SyncProviderServiceInterface<SyncProviderId> & OperationSyncCapable,
|
||||
syncProvider: SyncProviderBase<SyncProviderId> & OperationSyncCapable,
|
||||
serverSeq: number | undefined,
|
||||
logPrefix?: string,
|
||||
): Promise<void> {
|
||||
|
|
@ -191,4 +194,72 @@ export class SnapshotUploadService {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all server data and uploads a fresh snapshot with new encryption settings.
|
||||
*
|
||||
* Common pattern used by both encryption toggle and import encryption handler.
|
||||
* Validates crypto availability, gathers state, encrypts if needed, deletes
|
||||
* server data, updates provider config, uploads snapshot, and updates lastServerSeq.
|
||||
*
|
||||
* Error handling (throw vs return result) remains the caller's responsibility.
|
||||
*
|
||||
* @throws WebCryptoNotAvailableError if encryption is enabled but WebCrypto is unavailable
|
||||
*/
|
||||
async deleteAndReuploadWithNewEncryption(options: {
|
||||
encryptKey: string | undefined;
|
||||
isEncryptionEnabled: boolean;
|
||||
logPrefix: string;
|
||||
}): Promise<SnapshotUploadResult & { existingCfg: SuperSyncPrivateCfg | null }> {
|
||||
const { encryptKey, isEncryptionEnabled, logPrefix } = options;
|
||||
|
||||
// Validate crypto availability before any destructive action
|
||||
if (isEncryptionEnabled && !isCryptoSubtleAvailable()) {
|
||||
throw new WebCryptoNotAvailableError(
|
||||
'Cannot enable encryption: WebCrypto API is not available. ' +
|
||||
'Encryption requires a secure context (HTTPS). ' +
|
||||
'On Android, encryption is not supported.',
|
||||
);
|
||||
}
|
||||
|
||||
const { syncProvider, existingCfg, state, vectorClock, clientId } =
|
||||
await this.gatherSnapshotData(logPrefix);
|
||||
|
||||
// Encrypt before delete (fail-early)
|
||||
let payload: unknown = state;
|
||||
if (isEncryptionEnabled && encryptKey) {
|
||||
SyncLog.normal(`${logPrefix}: Encrypting snapshot...`);
|
||||
payload = await this._encryptionService.encryptPayload(state, encryptKey);
|
||||
}
|
||||
|
||||
// Delete all server data
|
||||
SyncLog.normal(`${logPrefix}: Deleting server data...`);
|
||||
await syncProvider.deleteAllData();
|
||||
|
||||
// Update config before upload
|
||||
SyncLog.normal(`${logPrefix}: Updating provider config...`);
|
||||
await this._providerManager.setProviderConfig(SyncProviderId.SuperSync, {
|
||||
...existingCfg,
|
||||
encryptKey: isEncryptionEnabled ? encryptKey : undefined,
|
||||
isEncryptionEnabled,
|
||||
} as SuperSyncPrivateCfg);
|
||||
|
||||
// Upload snapshot
|
||||
SyncLog.normal(`${logPrefix}: Uploading snapshot...`);
|
||||
const result = await this.uploadSnapshot(
|
||||
syncProvider,
|
||||
payload,
|
||||
clientId,
|
||||
vectorClock,
|
||||
isEncryptionEnabled && !!encryptKey,
|
||||
);
|
||||
|
||||
if (!result.accepted) {
|
||||
throw new Error(`Snapshot upload failed: ${result.error}`);
|
||||
}
|
||||
|
||||
await this.updateLastServerSeq(syncProvider, result.serverSeq, logPrefix);
|
||||
|
||||
return { ...result, existingCfg };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { SuperSyncEncryptionToggleService } from './supersync-encryption-toggle.service';
|
||||
import { SnapshotUploadData, SnapshotUploadService } from './snapshot-upload.service';
|
||||
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
|
||||
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
|
||||
import { SnapshotUploadService } from './snapshot-upload.service';
|
||||
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
|
||||
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
||||
import {
|
||||
|
|
@ -14,8 +12,6 @@ import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/supe
|
|||
describe('SuperSyncEncryptionToggleService', () => {
|
||||
let service: SuperSyncEncryptionToggleService;
|
||||
let mockSnapshotUploadService: jasmine.SpyObj<SnapshotUploadService>;
|
||||
let mockEncryptionService: jasmine.SpyObj<OperationEncryptionService>;
|
||||
let mockWrappedProviderService: jasmine.SpyObj<WrappedProviderService>;
|
||||
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
|
||||
let mockSyncProvider: jasmine.SpyObj<
|
||||
SyncProviderServiceInterface<SyncProviderId> & OperationSyncCapable
|
||||
|
|
@ -28,10 +24,6 @@ describe('SuperSyncEncryptionToggleService', () => {
|
|||
encryptKey: undefined,
|
||||
};
|
||||
|
||||
const mockState = { task: [], project: [] };
|
||||
const mockVectorClock = { testClient1: 1 };
|
||||
const mockClientId = 'testClient1';
|
||||
|
||||
beforeEach(() => {
|
||||
mockSyncProvider = jasmine.createSpyObj('SyncProvider', [
|
||||
'deleteAllData',
|
||||
|
|
@ -53,38 +45,18 @@ describe('SuperSyncEncryptionToggleService', () => {
|
|||
mockProviderManager.setProviderConfig.and.resolveTo();
|
||||
|
||||
mockSnapshotUploadService = jasmine.createSpyObj('SnapshotUploadService', [
|
||||
'gatherSnapshotData',
|
||||
'uploadSnapshot',
|
||||
'updateLastServerSeq',
|
||||
'deleteAndReuploadWithNewEncryption',
|
||||
]);
|
||||
mockSnapshotUploadService.gatherSnapshotData.and.resolveTo({
|
||||
syncProvider: mockSyncProvider,
|
||||
existingCfg: mockExistingCfg,
|
||||
state: mockState,
|
||||
vectorClock: mockVectorClock,
|
||||
clientId: mockClientId,
|
||||
} as unknown as SnapshotUploadData);
|
||||
mockSnapshotUploadService.uploadSnapshot.and.resolveTo({
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption.and.resolveTo({
|
||||
accepted: true,
|
||||
serverSeq: 42,
|
||||
existingCfg: mockExistingCfg,
|
||||
});
|
||||
mockSnapshotUploadService.updateLastServerSeq.and.resolveTo();
|
||||
|
||||
mockEncryptionService = jasmine.createSpyObj('OperationEncryptionService', [
|
||||
'encryptPayload',
|
||||
]);
|
||||
mockEncryptionService.encryptPayload.and.resolveTo('encrypted-state-data');
|
||||
|
||||
mockWrappedProviderService = jasmine.createSpyObj('WrappedProviderService', [
|
||||
'clearCache',
|
||||
]);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
SuperSyncEncryptionToggleService,
|
||||
{ provide: SnapshotUploadService, useValue: mockSnapshotUploadService },
|
||||
{ provide: OperationEncryptionService, useValue: mockEncryptionService },
|
||||
{ provide: WrappedProviderService, useValue: mockWrappedProviderService },
|
||||
{ provide: SyncProviderManager, useValue: mockProviderManager },
|
||||
],
|
||||
});
|
||||
|
|
@ -108,372 +80,118 @@ describe('SuperSyncEncryptionToggleService', () => {
|
|||
|
||||
await service.enableEncryption('new-key');
|
||||
|
||||
expect(mockSnapshotUploadService.gatherSnapshotData).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete server data before enabling encryption', async () => {
|
||||
it('should delegate to deleteAndReuploadWithNewEncryption with correct params', async () => {
|
||||
await service.enableEncryption('my-secret-key');
|
||||
|
||||
expect(mockSyncProvider.deleteAllData).toHaveBeenCalledTimes(1);
|
||||
expect(mockSyncProvider.deleteAllData).toHaveBeenCalledBefore(
|
||||
mockSnapshotUploadService.uploadSnapshot,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update config BEFORE upload via providerManager.setProviderConfig', async () => {
|
||||
await service.enableEncryption('my-secret-key');
|
||||
|
||||
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
|
||||
SyncProviderId.SuperSync,
|
||||
jasmine.objectContaining({
|
||||
encryptKey: 'my-secret-key',
|
||||
isEncryptionEnabled: true,
|
||||
}),
|
||||
);
|
||||
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledBefore(
|
||||
mockSnapshotUploadService.uploadSnapshot,
|
||||
);
|
||||
});
|
||||
|
||||
it('should encrypt the state before uploading', async () => {
|
||||
await service.enableEncryption('my-secret-key');
|
||||
|
||||
expect(mockEncryptionService.encryptPayload).toHaveBeenCalledWith(
|
||||
mockState,
|
||||
'my-secret-key',
|
||||
);
|
||||
});
|
||||
|
||||
it('should upload encrypted snapshot with isPayloadEncrypted=true', async () => {
|
||||
await service.enableEncryption('my-secret-key');
|
||||
|
||||
expect(mockSnapshotUploadService.uploadSnapshot).toHaveBeenCalledWith(
|
||||
mockSyncProvider as any,
|
||||
'encrypted-state-data',
|
||||
mockClientId,
|
||||
mockVectorClock,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update lastServerSeq after successful upload', async () => {
|
||||
await service.enableEncryption('my-secret-key');
|
||||
|
||||
expect(mockSnapshotUploadService.updateLastServerSeq).toHaveBeenCalledWith(
|
||||
mockSyncProvider as any,
|
||||
42,
|
||||
'SuperSyncEncryptionToggleService',
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear wrapped provider cache after config update', async () => {
|
||||
await service.enableEncryption('my-secret-key');
|
||||
|
||||
expect(mockWrappedProviderService.clearCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should revert config on upload failure', async () => {
|
||||
mockSnapshotUploadService.uploadSnapshot.and.resolveTo({
|
||||
accepted: false,
|
||||
error: 'Server rejected',
|
||||
expect(
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption,
|
||||
).toHaveBeenCalledWith({
|
||||
encryptKey: 'my-secret-key',
|
||||
isEncryptionEnabled: true,
|
||||
logPrefix: 'SuperSyncEncryptionToggleService',
|
||||
});
|
||||
});
|
||||
|
||||
it('should revert config on failure while preserving auth credentials', async () => {
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption.and.rejectWith(
|
||||
new Error('Upload failed'),
|
||||
);
|
||||
|
||||
await expectAsync(service.enableEncryption('my-secret-key')).toBeRejectedWithError(
|
||||
/CRITICAL/,
|
||||
);
|
||||
|
||||
// First call: set new config (before upload)
|
||||
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
|
||||
SyncProviderId.SuperSync,
|
||||
jasmine.objectContaining({
|
||||
encryptKey: 'my-secret-key',
|
||||
isEncryptionEnabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Second call: revert config (after upload failure)
|
||||
// Should revert config to disable encryption while keeping baseUrl/accessToken
|
||||
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
|
||||
SyncProviderId.SuperSync,
|
||||
jasmine.objectContaining({
|
||||
baseUrl: 'https://test.example.com',
|
||||
accessToken: 'test-token',
|
||||
encryptKey: undefined,
|
||||
isEncryptionEnabled: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should throw with CRITICAL message on upload failure', async () => {
|
||||
mockSnapshotUploadService.uploadSnapshot.and.resolveTo({
|
||||
accepted: false,
|
||||
error: 'Server rejected',
|
||||
});
|
||||
it('should throw with CRITICAL message on failure', async () => {
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption.and.rejectWith(
|
||||
new Error('Server rejected'),
|
||||
);
|
||||
|
||||
await expectAsync(service.enableEncryption('my-secret-key')).toBeRejectedWithError(
|
||||
/CRITICAL: Failed to upload encrypted snapshot after deleting server data/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw with CRITICAL message when uploadSnapshot throws', async () => {
|
||||
mockSnapshotUploadService.uploadSnapshot.and.rejectWith(new Error('Network error'));
|
||||
it('should include original error message in CRITICAL error', async () => {
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption.and.rejectWith(
|
||||
new Error('Network error'),
|
||||
);
|
||||
|
||||
await expectAsync(service.enableEncryption('my-secret-key')).toBeRejectedWithError(
|
||||
/CRITICAL.*Network error/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve existing config properties when enabling encryption', async () => {
|
||||
const existingCfg: SuperSyncPrivateCfg = {
|
||||
baseUrl: 'https://custom-server.com',
|
||||
accessToken: 'my-access-token',
|
||||
refreshToken: 'my-refresh-token',
|
||||
isEncryptionEnabled: false,
|
||||
describe('disableEncryption', () => {
|
||||
it('should delegate to deleteAndReuploadWithNewEncryption with correct params', async () => {
|
||||
await service.disableEncryption();
|
||||
|
||||
expect(
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption,
|
||||
).toHaveBeenCalledWith({
|
||||
encryptKey: undefined,
|
||||
};
|
||||
mockSnapshotUploadService.gatherSnapshotData.and.resolveTo({
|
||||
syncProvider: mockSyncProvider,
|
||||
existingCfg,
|
||||
state: mockState,
|
||||
vectorClock: mockVectorClock,
|
||||
clientId: mockClientId,
|
||||
} as unknown as SnapshotUploadData);
|
||||
isEncryptionEnabled: false,
|
||||
logPrefix: 'SuperSyncEncryptionToggleService',
|
||||
});
|
||||
});
|
||||
|
||||
await service.enableEncryption('my-secret-key');
|
||||
it('should revert config to re-enable encryption on failure', async () => {
|
||||
// After shared method runs, config is already set to isEncryptionEnabled: false
|
||||
(mockSyncProvider.privateCfg.load as jasmine.Spy).and.resolveTo({
|
||||
...mockExistingCfg,
|
||||
isEncryptionEnabled: false,
|
||||
});
|
||||
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption.and.rejectWith(
|
||||
new Error('Upload failed'),
|
||||
);
|
||||
|
||||
await expectAsync(service.disableEncryption()).toBeRejectedWithError(/CRITICAL/);
|
||||
|
||||
// Should revert config to re-enable encryption
|
||||
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
|
||||
SyncProviderId.SuperSync,
|
||||
jasmine.objectContaining({
|
||||
baseUrl: 'https://custom-server.com',
|
||||
accessToken: 'my-access-token',
|
||||
refreshToken: 'my-refresh-token',
|
||||
encryptKey: 'my-secret-key',
|
||||
isEncryptionEnabled: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should execute steps in correct order', async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
mockSnapshotUploadService.gatherSnapshotData.and.callFake(async () => {
|
||||
callOrder.push('gatherSnapshotData');
|
||||
return {
|
||||
syncProvider: mockSyncProvider,
|
||||
existingCfg: mockExistingCfg,
|
||||
state: mockState,
|
||||
vectorClock: mockVectorClock,
|
||||
clientId: mockClientId,
|
||||
} as unknown as SnapshotUploadData;
|
||||
});
|
||||
|
||||
mockSyncProvider.deleteAllData.and.callFake(async () => {
|
||||
callOrder.push('deleteAllData');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
mockProviderManager.setProviderConfig.and.callFake(async () => {
|
||||
callOrder.push('setProviderConfig');
|
||||
});
|
||||
|
||||
mockWrappedProviderService.clearCache.and.callFake(() => {
|
||||
callOrder.push('clearCache');
|
||||
});
|
||||
|
||||
mockEncryptionService.encryptPayload.and.callFake(async () => {
|
||||
callOrder.push('encryptPayload');
|
||||
return 'encrypted-data';
|
||||
});
|
||||
|
||||
mockSnapshotUploadService.uploadSnapshot.and.callFake(async () => {
|
||||
callOrder.push('uploadSnapshot');
|
||||
return { accepted: true, serverSeq: 42 };
|
||||
});
|
||||
|
||||
mockSnapshotUploadService.updateLastServerSeq.and.callFake(async () => {
|
||||
callOrder.push('updateLastServerSeq');
|
||||
});
|
||||
|
||||
await service.enableEncryption('my-secret-key');
|
||||
|
||||
expect(callOrder).toEqual([
|
||||
'gatherSnapshotData',
|
||||
'encryptPayload',
|
||||
'deleteAllData',
|
||||
'setProviderConfig',
|
||||
'clearCache',
|
||||
'uploadSnapshot',
|
||||
'updateLastServerSeq',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableEncryption', () => {
|
||||
it('should delete server data before disabling encryption', async () => {
|
||||
await service.disableEncryption();
|
||||
|
||||
expect(mockSyncProvider.deleteAllData).toHaveBeenCalledTimes(1);
|
||||
expect(mockSyncProvider.deleteAllData).toHaveBeenCalledBefore(
|
||||
mockSnapshotUploadService.uploadSnapshot,
|
||||
it('should throw with CRITICAL message on failure', async () => {
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption.and.rejectWith(
|
||||
new Error('Server rejected'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should upload unencrypted snapshot with isPayloadEncrypted=false', async () => {
|
||||
await service.disableEncryption();
|
||||
|
||||
expect(mockSnapshotUploadService.uploadSnapshot).toHaveBeenCalledWith(
|
||||
mockSyncProvider as any,
|
||||
mockState,
|
||||
mockClientId,
|
||||
mockVectorClock,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update config with encryption disabled AFTER successful upload', async () => {
|
||||
await service.disableEncryption();
|
||||
|
||||
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
|
||||
SyncProviderId.SuperSync,
|
||||
jasmine.objectContaining({
|
||||
encryptKey: undefined,
|
||||
isEncryptionEnabled: false,
|
||||
}),
|
||||
);
|
||||
// Upload should happen BEFORE config update
|
||||
expect(mockSnapshotUploadService.uploadSnapshot).toHaveBeenCalledBefore(
|
||||
mockProviderManager.setProviderConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update lastServerSeq after successful upload', async () => {
|
||||
await service.disableEncryption();
|
||||
|
||||
expect(mockSnapshotUploadService.updateLastServerSeq).toHaveBeenCalledWith(
|
||||
mockSyncProvider as any,
|
||||
42,
|
||||
'SuperSyncEncryptionToggleService',
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear wrapped provider cache after disabling', async () => {
|
||||
await service.disableEncryption();
|
||||
|
||||
expect(mockWrappedProviderService.clearCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT update config on upload failure', async () => {
|
||||
mockSnapshotUploadService.uploadSnapshot.and.resolveTo({
|
||||
accepted: false,
|
||||
error: 'Server rejected',
|
||||
});
|
||||
|
||||
await expectAsync(service.disableEncryption()).toBeRejectedWithError(/CRITICAL/);
|
||||
|
||||
// disableEncryption updates config AFTER upload — on failure, config should not be updated
|
||||
expect(mockProviderManager.setProviderConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw with CRITICAL message on upload failure', async () => {
|
||||
mockSnapshotUploadService.uploadSnapshot.and.resolveTo({
|
||||
accepted: false,
|
||||
error: 'Server rejected',
|
||||
});
|
||||
|
||||
await expectAsync(service.disableEncryption()).toBeRejectedWithError(
|
||||
/CRITICAL: Failed to upload unencrypted snapshot after deleting server data/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw with CRITICAL message when uploadSnapshot throws', async () => {
|
||||
mockSnapshotUploadService.uploadSnapshot.and.rejectWith(new Error('Network error'));
|
||||
it('should include original error message in CRITICAL error', async () => {
|
||||
mockSnapshotUploadService.deleteAndReuploadWithNewEncryption.and.rejectWith(
|
||||
new Error('Network error'),
|
||||
);
|
||||
|
||||
await expectAsync(service.disableEncryption()).toBeRejectedWithError(
|
||||
/CRITICAL.*Network error/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve other config properties when disabling encryption', async () => {
|
||||
const existingCfg: SuperSyncPrivateCfg = {
|
||||
baseUrl: 'https://custom-server.com',
|
||||
accessToken: 'my-access-token',
|
||||
refreshToken: 'my-refresh-token',
|
||||
isEncryptionEnabled: true,
|
||||
encryptKey: 'old-key',
|
||||
};
|
||||
mockSnapshotUploadService.gatherSnapshotData.and.resolveTo({
|
||||
syncProvider: mockSyncProvider,
|
||||
existingCfg,
|
||||
state: mockState,
|
||||
vectorClock: mockVectorClock,
|
||||
clientId: mockClientId,
|
||||
} as unknown as SnapshotUploadData);
|
||||
|
||||
await service.disableEncryption();
|
||||
|
||||
expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith(
|
||||
SyncProviderId.SuperSync,
|
||||
jasmine.objectContaining({
|
||||
baseUrl: 'https://custom-server.com',
|
||||
accessToken: 'my-access-token',
|
||||
refreshToken: 'my-refresh-token',
|
||||
encryptKey: undefined,
|
||||
isEncryptionEnabled: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT encrypt the payload', async () => {
|
||||
await service.disableEncryption();
|
||||
|
||||
expect(mockEncryptionService.encryptPayload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should execute steps in correct order', async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
mockSnapshotUploadService.gatherSnapshotData.and.callFake(async () => {
|
||||
callOrder.push('gatherSnapshotData');
|
||||
return {
|
||||
syncProvider: mockSyncProvider,
|
||||
existingCfg: mockExistingCfg,
|
||||
state: mockState,
|
||||
vectorClock: mockVectorClock,
|
||||
clientId: mockClientId,
|
||||
} as unknown as SnapshotUploadData;
|
||||
});
|
||||
|
||||
mockSyncProvider.deleteAllData.and.callFake(async () => {
|
||||
callOrder.push('deleteAllData');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
mockSnapshotUploadService.uploadSnapshot.and.callFake(async () => {
|
||||
callOrder.push('uploadSnapshot');
|
||||
return { accepted: true, serverSeq: 42 };
|
||||
});
|
||||
|
||||
mockSnapshotUploadService.updateLastServerSeq.and.callFake(async () => {
|
||||
callOrder.push('updateLastServerSeq');
|
||||
});
|
||||
|
||||
mockProviderManager.setProviderConfig.and.callFake(async () => {
|
||||
callOrder.push('setProviderConfig');
|
||||
});
|
||||
|
||||
mockWrappedProviderService.clearCache.and.callFake(() => {
|
||||
callOrder.push('clearCache');
|
||||
});
|
||||
|
||||
await service.disableEncryption();
|
||||
|
||||
expect(callOrder).toEqual([
|
||||
'gatherSnapshotData',
|
||||
'deleteAllData',
|
||||
'uploadSnapshot',
|
||||
'updateLastServerSeq',
|
||||
'setProviderConfig',
|
||||
'clearCache',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,36 +2,30 @@ import { inject, Injectable } from '@angular/core';
|
|||
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { SnapshotUploadService } from './snapshot-upload.service';
|
||||
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
|
||||
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
|
||||
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
|
||||
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
||||
import { isCryptoSubtleAvailable } from '../../op-log/encryption/encryption';
|
||||
import { WebCryptoNotAvailableError } from '../../op-log/core/errors/sync-errors';
|
||||
|
||||
const LOG_PREFIX = 'SuperSyncEncryptionToggleService';
|
||||
|
||||
/**
|
||||
* Service for enabling/disabling encryption for SuperSync.
|
||||
*
|
||||
* Enable flow: encrypt first, then delete, then upload (fail-early on encryption errors)
|
||||
* Disable flow: delete, then upload unencrypted, then update config
|
||||
* Both enable and disable flows delegate to SnapshotUploadService.deleteAndReuploadWithNewEncryption()
|
||||
* which handles: gather state -> encrypt (if enabling) -> delete server data -> update config -> upload.
|
||||
* This service adds toggle-specific concerns: guard against duplicate enable, config revert on failure.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SuperSyncEncryptionToggleService {
|
||||
private _snapshotUploadService = inject(SnapshotUploadService);
|
||||
private _encryptionService = inject(OperationEncryptionService);
|
||||
private _wrappedProviderService = inject(WrappedProviderService);
|
||||
private _providerManager = inject(SyncProviderManager);
|
||||
|
||||
/**
|
||||
* Enables encryption:
|
||||
* 1. Encrypt snapshot (fail-early before any destructive action)
|
||||
* 2. Delete all server data
|
||||
* 3. Update config BEFORE upload so the upload uses the new key
|
||||
* 4. Upload encrypted snapshot (reverts config on failure)
|
||||
* 1. Guard against duplicate calls
|
||||
* 2. Delegate to deleteAndReuploadWithNewEncryption (encrypt, delete, config, upload)
|
||||
* 3. Clear cache on success, revert config on failure
|
||||
*/
|
||||
async enableEncryption(encryptKey: string): Promise<void> {
|
||||
SyncLog.normal(`${LOG_PREFIX}: Starting encryption enable...`);
|
||||
|
|
@ -54,99 +48,49 @@ export class SuperSyncEncryptionToggleService {
|
|||
}
|
||||
}
|
||||
|
||||
// Check crypto availability BEFORE deleting server data
|
||||
if (!isCryptoSubtleAvailable()) {
|
||||
throw new WebCryptoNotAvailableError(
|
||||
'Cannot enable encryption: WebCrypto API is not available. ' +
|
||||
'Encryption requires a secure context (HTTPS). ' +
|
||||
'On Android, encryption is not supported.',
|
||||
);
|
||||
}
|
||||
|
||||
const { syncProvider, existingCfg, state, vectorClock, clientId } =
|
||||
await this._snapshotUploadService.gatherSnapshotData(LOG_PREFIX);
|
||||
|
||||
// Encrypt BEFORE deleting server data to fail early if encryption fails
|
||||
SyncLog.normal(`${LOG_PREFIX}: Encrypting snapshot...`);
|
||||
const encryptedPayload = await this._encryptionService.encryptPayload(
|
||||
state,
|
||||
encryptKey,
|
||||
);
|
||||
|
||||
SyncLog.normal(`${LOG_PREFIX}: Deleting server data...`);
|
||||
await syncProvider.deleteAllData();
|
||||
|
||||
try {
|
||||
// Update config BEFORE upload so the upload uses the new key
|
||||
SyncLog.normal(`${LOG_PREFIX}: Updating local config...`);
|
||||
const newConfig = {
|
||||
...existingCfg,
|
||||
await this._snapshotUploadService.deleteAndReuploadWithNewEncryption({
|
||||
encryptKey,
|
||||
isEncryptionEnabled: true,
|
||||
} as SuperSyncPrivateCfg;
|
||||
await this._providerManager.setProviderConfig(SyncProviderId.SuperSync, newConfig);
|
||||
logPrefix: LOG_PREFIX,
|
||||
});
|
||||
|
||||
this._wrappedProviderService.clearCache();
|
||||
|
||||
await this._uploadAndFinalize(
|
||||
syncProvider,
|
||||
encryptedPayload,
|
||||
clientId,
|
||||
vectorClock,
|
||||
true,
|
||||
);
|
||||
SyncLog.normal(`${LOG_PREFIX}: Encryption enabled successfully!`);
|
||||
} catch (uploadError) {
|
||||
// Revert config on failure
|
||||
SyncLog.err(`${LOG_PREFIX}: Failed after deleting server data!`, uploadError);
|
||||
} catch (error) {
|
||||
// Revert config on failure (server data is already deleted at this point)
|
||||
SyncLog.err(`${LOG_PREFIX}: Failed after deleting server data!`, error);
|
||||
|
||||
const revertConfig = {
|
||||
...existingCfg,
|
||||
// Best-effort revert: load current cfg to preserve auth credentials (baseUrl, accessToken, etc.)
|
||||
const currentCfg = await this._providerManager
|
||||
.getActiveProvider()
|
||||
?.privateCfg.load();
|
||||
await this._providerManager.setProviderConfig(SyncProviderId.SuperSync, {
|
||||
...currentCfg,
|
||||
encryptKey: undefined,
|
||||
isEncryptionEnabled: false,
|
||||
} as SuperSyncPrivateCfg;
|
||||
await this._providerManager.setProviderConfig(
|
||||
SyncProviderId.SuperSync,
|
||||
revertConfig,
|
||||
);
|
||||
this._wrappedProviderService.clearCache();
|
||||
} as SuperSyncPrivateCfg);
|
||||
|
||||
throw new Error(
|
||||
'CRITICAL: Failed to upload encrypted snapshot after deleting server data. ' +
|
||||
'Your local data is safe. Encryption has been reverted. Please use "Sync Now" to re-upload your data. ' +
|
||||
`Original error: ${uploadError instanceof Error ? uploadError.message : uploadError}`,
|
||||
`Original error: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables encryption by deleting all server data and uploading an unencrypted snapshot.
|
||||
* Config is updated AFTER successful upload for safety.
|
||||
*/
|
||||
async disableEncryption(): Promise<void> {
|
||||
SyncLog.normal(`${LOG_PREFIX}: Starting encryption disable...`);
|
||||
|
||||
const { syncProvider, existingCfg, state, vectorClock, clientId } =
|
||||
await this._snapshotUploadService.gatherSnapshotData(LOG_PREFIX);
|
||||
|
||||
SyncLog.normal(`${LOG_PREFIX}: Deleting server data...`);
|
||||
await syncProvider.deleteAllData();
|
||||
|
||||
SyncLog.normal(`${LOG_PREFIX}: Uploading unencrypted snapshot...`);
|
||||
try {
|
||||
await this._uploadAndFinalize(syncProvider, state, clientId, vectorClock, false);
|
||||
|
||||
// Update config AFTER successful upload
|
||||
// IMPORTANT: Use providerManager.setProviderConfig() instead of direct setPrivateCfg()
|
||||
// to ensure the currentProviderPrivateCfg$ observable is updated.
|
||||
SyncLog.normal(`${LOG_PREFIX}: Updating local config...`);
|
||||
await this._providerManager.setProviderConfig(SyncProviderId.SuperSync, {
|
||||
...existingCfg,
|
||||
await this._snapshotUploadService.deleteAndReuploadWithNewEncryption({
|
||||
encryptKey: undefined,
|
||||
isEncryptionEnabled: false,
|
||||
} as SuperSyncPrivateCfg);
|
||||
logPrefix: LOG_PREFIX,
|
||||
});
|
||||
|
||||
this._wrappedProviderService.clearCache();
|
||||
SyncLog.normal(`${LOG_PREFIX}: Encryption disabled successfully!`);
|
||||
} catch (uploadError) {
|
||||
SyncLog.err(
|
||||
|
|
@ -154,6 +98,17 @@ export class SuperSyncEncryptionToggleService {
|
|||
uploadError,
|
||||
);
|
||||
|
||||
// Best-effort revert: re-enable encryption since disable failed
|
||||
const currentCfg = (await this._providerManager
|
||||
.getActiveProvider()
|
||||
?.privateCfg.load()) as SuperSyncPrivateCfg | undefined;
|
||||
if (currentCfg && !currentCfg.isEncryptionEnabled) {
|
||||
await this._providerManager.setProviderConfig(SyncProviderId.SuperSync, {
|
||||
...currentCfg,
|
||||
isEncryptionEnabled: true,
|
||||
} as SuperSyncPrivateCfg);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'CRITICAL: Failed to upload unencrypted snapshot after deleting server data. ' +
|
||||
'Your local data is safe. Encryption is still enabled. Please try disabling encryption again. ' +
|
||||
|
|
@ -161,30 +116,4 @@ export class SuperSyncEncryptionToggleService {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async _uploadAndFinalize(
|
||||
syncProvider: Parameters<SnapshotUploadService['uploadSnapshot']>[0],
|
||||
payload: unknown,
|
||||
clientId: string,
|
||||
vectorClock: Record<string, number>,
|
||||
isPayloadEncrypted: boolean,
|
||||
): Promise<void> {
|
||||
const result = await this._snapshotUploadService.uploadSnapshot(
|
||||
syncProvider,
|
||||
payload,
|
||||
clientId,
|
||||
vectorClock,
|
||||
isPayloadEncrypted,
|
||||
);
|
||||
|
||||
if (!result.accepted) {
|
||||
throw new Error(`Snapshot upload failed: ${result.error}`);
|
||||
}
|
||||
|
||||
await this._snapshotUploadService.updateLastServerSeq(
|
||||
syncProvider,
|
||||
result.serverSeq,
|
||||
LOG_PREFIX,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { SyncConfig } from '../../features/config/global-config.model';
|
|||
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
|
||||
import { first } from 'rxjs/operators';
|
||||
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
|
||||
import { SyncWrapperService } from './sync-wrapper.service';
|
||||
|
||||
describe('SyncConfigService', () => {
|
||||
|
|
@ -53,10 +52,6 @@ describe('SyncConfigService', () => {
|
|||
},
|
||||
);
|
||||
|
||||
const wrappedProviderServiceSpy = jasmine.createSpyObj('WrappedProviderService', [
|
||||
'clearCache',
|
||||
]);
|
||||
|
||||
const syncWrapperServiceSpy = jasmine.createSpyObj('SyncWrapperService', [
|
||||
'clearEncryptionDialogSuppression',
|
||||
]);
|
||||
|
|
@ -66,7 +61,6 @@ describe('SyncConfigService', () => {
|
|||
SyncConfigService,
|
||||
{ provide: SyncProviderManager, useValue: providerManagerSpy },
|
||||
{ provide: GlobalConfigService, useValue: globalConfigServiceSpy },
|
||||
{ provide: WrappedProviderService, useValue: wrappedProviderServiceSpy },
|
||||
{ provide: SyncWrapperService, useValue: syncWrapperServiceSpy },
|
||||
],
|
||||
});
|
||||
|
|
@ -1159,197 +1153,9 @@ describe('SyncConfigService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Cache Clearing on Encryption Changes', () => {
|
||||
let wrappedProviderService: jasmine.SpyObj<WrappedProviderService>;
|
||||
|
||||
beforeEach(() => {
|
||||
wrappedProviderService = TestBed.inject(
|
||||
WrappedProviderService,
|
||||
) as jasmine.SpyObj<WrappedProviderService>;
|
||||
});
|
||||
|
||||
it('should clear cache when encryption is disabled', async () => {
|
||||
// Mock existing provider config with encryption
|
||||
const mockProvider = {
|
||||
id: SyncProviderId.WebDAV,
|
||||
privateCfg: {
|
||||
load: jasmine.createSpy('load').and.returnValue(
|
||||
Promise.resolve({
|
||||
baseUrl: 'https://example.com/dav',
|
||||
userName: 'user',
|
||||
password: 'pass',
|
||||
syncFolderPath: '/sync',
|
||||
encryptKey: 'oldPassword', // Has encryption
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
|
||||
|
||||
// Spy on cache clear
|
||||
const clearCacheSpy = spyOn(service['_derivedKeyCache'], 'clearCache');
|
||||
|
||||
// Update settings to disable encryption
|
||||
await service.updateSettingsFromForm({
|
||||
syncProvider: SyncProviderId.WebDAV as any,
|
||||
encryptKey: '', // No encryption key
|
||||
webDav: {
|
||||
baseUrl: 'https://example.com/dav',
|
||||
userName: 'user',
|
||||
password: 'pass',
|
||||
syncFolderPath: '/sync',
|
||||
},
|
||||
} as SyncConfig);
|
||||
|
||||
// Verify both caches were cleared
|
||||
expect(clearCacheSpy).toHaveBeenCalled();
|
||||
expect(wrappedProviderService.clearCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear cache when encryption password changes', async () => {
|
||||
// Mock existing provider config with old password
|
||||
const mockProvider = {
|
||||
id: SyncProviderId.WebDAV,
|
||||
privateCfg: {
|
||||
load: jasmine.createSpy('load').and.returnValue(
|
||||
Promise.resolve({
|
||||
baseUrl: 'https://example.com/dav',
|
||||
userName: 'user',
|
||||
password: 'pass',
|
||||
syncFolderPath: '/sync',
|
||||
encryptKey: 'oldPassword',
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
|
||||
|
||||
// Spy on cache clear
|
||||
const clearCacheSpy = spyOn(service['_derivedKeyCache'], 'clearCache');
|
||||
|
||||
// Update settings with new encryption password
|
||||
await service.updateSettingsFromForm({
|
||||
syncProvider: SyncProviderId.WebDAV as any,
|
||||
encryptKey: 'newPassword', // Different password
|
||||
webDav: {
|
||||
baseUrl: 'https://example.com/dav',
|
||||
userName: 'user',
|
||||
password: 'pass',
|
||||
syncFolderPath: '/sync',
|
||||
},
|
||||
} as SyncConfig);
|
||||
|
||||
// Verify both caches were cleared
|
||||
expect(clearCacheSpy).toHaveBeenCalled();
|
||||
expect(wrappedProviderService.clearCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear cache when encryption is enabled', async () => {
|
||||
// Mock existing provider config without encryption
|
||||
const mockProvider = {
|
||||
id: SyncProviderId.WebDAV,
|
||||
privateCfg: {
|
||||
load: jasmine.createSpy('load').and.returnValue(
|
||||
Promise.resolve({
|
||||
baseUrl: 'https://example.com/dav',
|
||||
userName: 'user',
|
||||
password: 'pass',
|
||||
syncFolderPath: '/sync',
|
||||
encryptKey: '', // No encryption initially
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
|
||||
|
||||
// Spy on cache clear
|
||||
const clearCacheSpy = spyOn(service['_derivedKeyCache'], 'clearCache');
|
||||
|
||||
// Update settings to enable encryption
|
||||
await service.updateSettingsFromForm({
|
||||
syncProvider: SyncProviderId.WebDAV as any,
|
||||
encryptKey: 'newPassword', // Enable encryption
|
||||
webDav: {
|
||||
baseUrl: 'https://example.com/dav',
|
||||
userName: 'user',
|
||||
password: 'pass',
|
||||
syncFolderPath: '/sync',
|
||||
},
|
||||
} as SyncConfig);
|
||||
|
||||
// Verify both caches were cleared
|
||||
expect(clearCacheSpy).toHaveBeenCalled();
|
||||
expect(wrappedProviderService.clearCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT clear cache when encryption key unchanged', async () => {
|
||||
// Mock existing provider config with encryption
|
||||
const mockProvider = {
|
||||
id: SyncProviderId.WebDAV,
|
||||
privateCfg: {
|
||||
load: jasmine.createSpy('load').and.returnValue(
|
||||
Promise.resolve({
|
||||
baseUrl: 'https://example.com/dav',
|
||||
userName: 'user',
|
||||
password: 'pass',
|
||||
syncFolderPath: '/sync',
|
||||
encryptKey: 'samePassword',
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
|
||||
|
||||
// Spy on cache clear
|
||||
const clearCacheSpy = spyOn(service['_derivedKeyCache'], 'clearCache');
|
||||
|
||||
// Update settings with same encryption password
|
||||
await service.updateSettingsFromForm({
|
||||
syncProvider: SyncProviderId.WebDAV as any,
|
||||
encryptKey: 'samePassword', // Same password
|
||||
webDav: {
|
||||
baseUrl: 'https://example.com/dav',
|
||||
userName: 'user',
|
||||
password: 'pass',
|
||||
syncFolderPath: '/sync',
|
||||
},
|
||||
} as SyncConfig);
|
||||
|
||||
// Verify neither cache was cleared
|
||||
expect(clearCacheSpy).not.toHaveBeenCalled();
|
||||
expect(wrappedProviderService.clearCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear cache via updateEncryptionPassword method', async () => {
|
||||
// Mock existing provider config
|
||||
const mockProvider = {
|
||||
id: SyncProviderId.WebDAV,
|
||||
privateCfg: {
|
||||
load: jasmine.createSpy('load').and.returnValue(
|
||||
Promise.resolve({
|
||||
baseUrl: 'https://example.com/dav',
|
||||
userName: 'user',
|
||||
password: 'pass',
|
||||
syncFolderPath: '/sync',
|
||||
encryptKey: 'oldPassword',
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
|
||||
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(mockProvider);
|
||||
|
||||
// Spy on cache clear
|
||||
const clearCacheSpy = spyOn(service['_derivedKeyCache'], 'clearCache');
|
||||
|
||||
// Update password via dedicated method
|
||||
await service.updateEncryptionPassword('newPassword', SyncProviderId.WebDAV);
|
||||
|
||||
// Verify both caches were cleared
|
||||
expect(clearCacheSpy).toHaveBeenCalled();
|
||||
expect(wrappedProviderService.clearCache).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
// Note: Cache clearing tests for WrappedProviderService have been removed because
|
||||
// WrappedProviderService now auto-invalidates its cache via providerConfigChanged$
|
||||
// subscription. See wrapped-provider.service.spec.ts for cache invalidation tests.
|
||||
|
||||
/**
|
||||
* Tests for SuperSync password preservation race condition fix
|
||||
|
|
|
|||
|
|
@ -11,9 +11,8 @@ import {
|
|||
} from '../../op-log/sync-exports';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { DerivedKeyCacheService } from '../../op-log/encryption/derived-key-cache.service';
|
||||
import { clearSessionKeyCache } from '../../op-log/encryption/encryption';
|
||||
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
|
||||
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
|
||||
import { SyncWrapperService } from './sync-wrapper.service';
|
||||
|
||||
// Maps sync providers to their corresponding form field in SyncConfig
|
||||
|
|
@ -91,8 +90,6 @@ const PROVIDER_FIELD_DEFAULTS: Record<
|
|||
export class SyncConfigService {
|
||||
private _providerManager = inject(SyncProviderManager);
|
||||
private _globalConfigService = inject(GlobalConfigService);
|
||||
private _derivedKeyCache = inject(DerivedKeyCacheService);
|
||||
private _wrappedProvider = inject(WrappedProviderService);
|
||||
private _syncWrapper = inject(SyncWrapperService);
|
||||
|
||||
private _lastSettings: SyncConfig | null = null;
|
||||
|
|
@ -257,9 +254,7 @@ export class SyncConfigService {
|
|||
await this._providerManager.setProviderConfig(activeProvider.id, newConfig);
|
||||
|
||||
// Clear cached encryption keys to force re-derivation with new password
|
||||
this._derivedKeyCache.clearCache();
|
||||
// Clear cached adapters to force recreation with new encryption settings
|
||||
this._wrappedProvider.clearCache();
|
||||
clearSessionKeyCache();
|
||||
// Allow encryption dialogs to appear again after password change
|
||||
this._syncWrapper.clearEncryptionDialogSuppression();
|
||||
}
|
||||
|
|
@ -397,9 +392,7 @@ export class SyncConfigService {
|
|||
SyncLog.normal(
|
||||
'SyncConfigService: Encryption settings changed, clearing cached keys',
|
||||
);
|
||||
this._derivedKeyCache.clearCache();
|
||||
// Clear cached adapters to force recreation with new encryption settings
|
||||
this._wrappedProvider.clearCache();
|
||||
clearSessionKeyCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { clearSessionKeyCache, getSessionKeyCacheStats } from './encryption';
|
||||
|
||||
/**
|
||||
* Angular service wrapper for session-level encryption key caching.
|
||||
*
|
||||
* PERFORMANCE OPTIMIZATION:
|
||||
* Argon2id key derivation is expensive (64MB memory, 3 iterations, 500ms-2000ms on mobile).
|
||||
* The underlying cache (in encryption.ts) stores derived keys for the session duration,
|
||||
* so subsequent sync operations can reuse the same key without re-deriving.
|
||||
*
|
||||
* This service provides Angular DI integration for cache management.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DerivedKeyCacheService {
|
||||
/**
|
||||
* Clears the session key cache. Call this when:
|
||||
* - User changes their encryption password
|
||||
* - User logs out or disables encryption
|
||||
*/
|
||||
clearCache(): void {
|
||||
clearSessionKeyCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets statistics about the session key cache (for debugging/monitoring).
|
||||
*/
|
||||
getCacheStats(): { hasEncryptKey: boolean; decryptKeyCount: number } {
|
||||
return getSessionKeyCacheStats();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import { InjectionToken } from '@angular/core';
|
||||
import {
|
||||
encrypt,
|
||||
decrypt,
|
||||
decryptWithMigration,
|
||||
DecryptResult,
|
||||
encryptBatch,
|
||||
decryptBatch,
|
||||
} from './encryption';
|
||||
|
||||
/**
|
||||
* Injection token for the encrypt function.
|
||||
* Allows tests to provide a fast mock implementation.
|
||||
*/
|
||||
export const ENCRYPT_FN = new InjectionToken<typeof encrypt>('ENCRYPT_FN', {
|
||||
providedIn: 'root',
|
||||
factory: () => encrypt,
|
||||
});
|
||||
|
||||
/**
|
||||
* Injection token for the decrypt function.
|
||||
* Allows tests to provide a fast mock implementation.
|
||||
*/
|
||||
export const DECRYPT_FN = new InjectionToken<typeof decrypt>('DECRYPT_FN', {
|
||||
providedIn: 'root',
|
||||
factory: () => decrypt,
|
||||
});
|
||||
|
||||
/**
|
||||
* Injection token for the decrypt-with-migration function.
|
||||
* Use this when you need to handle legacy encryption migration.
|
||||
*/
|
||||
export const DECRYPT_WITH_MIGRATION_FN = new InjectionToken<typeof decryptWithMigration>(
|
||||
'DECRYPT_WITH_MIGRATION_FN',
|
||||
{
|
||||
providedIn: 'root',
|
||||
factory: () => decryptWithMigration,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Injection token for batch encrypt function.
|
||||
* Optimized for encrypting multiple items - derives key once instead of per-item.
|
||||
* Critical for mobile performance.
|
||||
*/
|
||||
export const ENCRYPT_BATCH_FN = new InjectionToken<typeof encryptBatch>(
|
||||
'ENCRYPT_BATCH_FN',
|
||||
{
|
||||
providedIn: 'root',
|
||||
factory: () => encryptBatch,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Injection token for batch decrypt function.
|
||||
* Optimized for decrypting multiple items - caches derived keys by salt.
|
||||
* Critical for mobile performance.
|
||||
*/
|
||||
export const DECRYPT_BATCH_FN = new InjectionToken<typeof decryptBatch>(
|
||||
'DECRYPT_BATCH_FN',
|
||||
{
|
||||
providedIn: 'root',
|
||||
factory: () => decryptBatch,
|
||||
},
|
||||
);
|
||||
|
||||
export type EncryptFn = (data: string, password: string) => Promise<string>;
|
||||
export type DecryptFn = (data: string, password: string) => Promise<string>;
|
||||
export type DecryptWithMigrationFn = (
|
||||
data: string,
|
||||
password: string,
|
||||
) => Promise<DecryptResult>;
|
||||
export type EncryptBatchFn = (dataItems: string[], password: string) => Promise<string[]>;
|
||||
export type DecryptBatchFn = (dataItems: string[], password: string) => Promise<string[]>;
|
||||
|
|
@ -50,8 +50,13 @@ export {
|
|||
SyncInvalidTimeValuesError,
|
||||
} from './core/errors/sync-errors';
|
||||
|
||||
// Provider interface
|
||||
export { SyncProviderServiceInterface } from './sync-providers/provider.interface';
|
||||
// Provider interfaces
|
||||
export {
|
||||
SyncProviderBase,
|
||||
FileSyncProvider,
|
||||
SyncProviderServiceInterface,
|
||||
isFileSyncProvider,
|
||||
} from './sync-providers/provider.interface';
|
||||
|
||||
// Providers
|
||||
export { Dropbox } from './sync-providers/file-based/dropbox/dropbox';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
||||
import {
|
||||
concatMap,
|
||||
distinctUntilChanged,
|
||||
|
|
@ -13,7 +13,7 @@ import { selectSyncConfig } from '../../features/config/store/global-config.redu
|
|||
import { DataInitStateService } from '../../core/data-init/data-init-state.service';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { SyncProviderId, toSyncProviderId } from './provider.const';
|
||||
import { SyncProviderServiceInterface } from './provider.interface';
|
||||
import { SyncProviderBase } from './provider.interface';
|
||||
import {
|
||||
EncryptAndCompressCfg,
|
||||
PrivateCfgByProviderId,
|
||||
|
|
@ -42,25 +42,25 @@ export type SyncStatusChangePayload =
|
|||
|
||||
/**
|
||||
* Array of all available sync providers
|
||||
* Cast to generic SyncProviderServiceInterface - each provider implements
|
||||
* Cast to generic SyncProviderBase - each provider implements
|
||||
* a specific SyncProviderId but we store them in a generic array.
|
||||
*/
|
||||
const SYNC_PROVIDERS: SyncProviderServiceInterface<SyncProviderId>[] = [
|
||||
const SYNC_PROVIDERS: SyncProviderBase<SyncProviderId>[] = [
|
||||
new Dropbox({
|
||||
appKey: DROPBOX_APP_KEY,
|
||||
basePath: environment.production ? `/` : `/DEV/`,
|
||||
}) as SyncProviderServiceInterface<SyncProviderId>,
|
||||
}) as SyncProviderBase<SyncProviderId>,
|
||||
new Webdav(
|
||||
environment.production ? undefined : `/DEV`,
|
||||
) as SyncProviderServiceInterface<SyncProviderId>,
|
||||
) as SyncProviderBase<SyncProviderId>,
|
||||
new SuperSyncProvider(
|
||||
environment.production ? undefined : `/DEV`,
|
||||
) as SyncProviderServiceInterface<SyncProviderId>,
|
||||
) as SyncProviderBase<SyncProviderId>,
|
||||
...(IS_ELECTRON
|
||||
? [new LocalFileSyncElectron() as SyncProviderServiceInterface<SyncProviderId>]
|
||||
? [new LocalFileSyncElectron() as SyncProviderBase<SyncProviderId>]
|
||||
: []),
|
||||
...(IS_ANDROID_WEB_VIEW
|
||||
? [new LocalFileSyncAndroid() as SyncProviderServiceInterface<SyncProviderId>]
|
||||
? [new LocalFileSyncAndroid() as SyncProviderBase<SyncProviderId>]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ export class SyncProviderManager {
|
|||
private _store = inject(Store);
|
||||
|
||||
// Current active provider
|
||||
private _activeProvider: SyncProviderServiceInterface<SyncProviderId> | null = null;
|
||||
private _activeProvider: SyncProviderBase<SyncProviderId> | null = null;
|
||||
private _activeProviderId$ = new BehaviorSubject<SyncProviderId | null>(null);
|
||||
|
||||
// Encryption/compression config
|
||||
|
|
@ -117,6 +117,9 @@ export class SyncProviderManager {
|
|||
private _currentProviderPrivateCfg$ =
|
||||
new BehaviorSubject<CurrentProviderPrivateCfg | null>(null);
|
||||
|
||||
// Emits whenever provider config is updated via setProviderConfig()
|
||||
private _providerConfigChanged$ = new Subject<void>();
|
||||
|
||||
/**
|
||||
* Observable for whether the sync provider is enabled and ready
|
||||
*/
|
||||
|
|
@ -154,6 +157,13 @@ export class SyncProviderManager {
|
|||
public readonly currentProviderPrivateCfg$: Observable<CurrentProviderPrivateCfg | null> =
|
||||
this._currentProviderPrivateCfg$.pipe(shareReplay(1));
|
||||
|
||||
/**
|
||||
* Emits whenever provider config is updated via setProviderConfig().
|
||||
* Used by WrappedProviderService to auto-invalidate its adapter cache.
|
||||
*/
|
||||
public readonly providerConfigChanged$: Observable<void> =
|
||||
this._providerConfigChanged$.asObservable();
|
||||
|
||||
/**
|
||||
* Config observable from store (after data init)
|
||||
*/
|
||||
|
|
@ -187,7 +197,7 @@ export class SyncProviderManager {
|
|||
/**
|
||||
* Gets the currently active sync provider
|
||||
*/
|
||||
getActiveProvider(): SyncProviderServiceInterface<SyncProviderId> | null {
|
||||
getActiveProvider(): SyncProviderBase<SyncProviderId> | null {
|
||||
return this._activeProvider;
|
||||
}
|
||||
|
||||
|
|
@ -196,14 +206,14 @@ export class SyncProviderManager {
|
|||
*/
|
||||
getProviderById(
|
||||
providerId: SyncProviderId,
|
||||
): SyncProviderServiceInterface<SyncProviderId> | undefined {
|
||||
): SyncProviderBase<SyncProviderId> | undefined {
|
||||
return SYNC_PROVIDERS.find((p) => p.id === providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all available sync providers
|
||||
*/
|
||||
getAllProviders(): SyncProviderServiceInterface<SyncProviderId>[] {
|
||||
getAllProviders(): SyncProviderBase<SyncProviderId>[] {
|
||||
return [...SYNC_PROVIDERS];
|
||||
}
|
||||
|
||||
|
|
@ -258,6 +268,9 @@ export class SyncProviderManager {
|
|||
// This is critical for server migration detection when switching users.
|
||||
await provider.setPrivateCfg(config);
|
||||
|
||||
// Notify subscribers (e.g., WrappedProviderService) that config changed
|
||||
this._providerConfigChanged$.next();
|
||||
|
||||
// If this is the active provider, update the current config observable
|
||||
if (this._activeProvider?.id === providerId) {
|
||||
this._currentProviderPrivateCfg$.next({
|
||||
|
|
|
|||
|
|
@ -24,24 +24,57 @@ export interface SyncProviderAuthHelper {
|
|||
}
|
||||
|
||||
/**
|
||||
* Core sync provider service interface
|
||||
* Base sync provider properties shared by all provider types.
|
||||
* Both file-based providers and operation-based providers (SuperSync) implement this.
|
||||
*/
|
||||
export interface SyncProviderServiceInterface<PID extends SyncProviderId> {
|
||||
export interface SyncProviderBase<PID extends SyncProviderId> {
|
||||
/** Unique identifier for this sync provider */
|
||||
id: PID;
|
||||
|
||||
/** Whether this provider supports force upload (overwriting conflicts) */
|
||||
isUploadForcePossible?: boolean;
|
||||
|
||||
/** Whether this provider is limited to syncing a single file */
|
||||
isLimitedToSingleFileSync?: boolean;
|
||||
|
||||
/** Maximum number of concurrent requests allowed */
|
||||
maxConcurrentRequests: number;
|
||||
|
||||
/** Store for provider-specific private configuration */
|
||||
privateCfg: SyncCredentialStore<PID>;
|
||||
|
||||
/**
|
||||
* Checks if the provider is ready to perform sync operations
|
||||
* @returns True if the provider is authenticated and ready
|
||||
*/
|
||||
isReady(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Gets authentication helper for initiating auth flows
|
||||
* @returns Auth helper object or undefined if not supported
|
||||
*/
|
||||
getAuthHelper?(): Promise<SyncProviderAuthHelper>;
|
||||
|
||||
/**
|
||||
* Updates the provider's private configuration
|
||||
* @param privateCfg New configuration to store
|
||||
*/
|
||||
setPrivateCfg(privateCfg: PrivateCfgByProviderId<PID>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clears authentication credentials while preserving non-auth config (e.g., encryptKey).
|
||||
* Called when auth errors occur to ensure re-auth flow can proceed.
|
||||
*/
|
||||
clearAuthCredentials?(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based sync provider (Dropbox, WebDAV, LocalFile).
|
||||
* Extends the base with file operations for reading/writing sync data.
|
||||
*/
|
||||
export interface FileSyncProvider<
|
||||
PID extends SyncProviderId,
|
||||
> extends SyncProviderBase<PID> {
|
||||
/** Whether this provider is limited to syncing a single file */
|
||||
isLimitedToSingleFileSync?: boolean;
|
||||
|
||||
/**
|
||||
* Gets the current revision for a file
|
||||
* @param targetPath Path to the file
|
||||
|
|
@ -88,32 +121,27 @@ export interface SyncProviderServiceInterface<PID extends SyncProviderId> {
|
|||
* @returns List of file names/paths
|
||||
*/
|
||||
listFiles?(targetPath: string): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Checks if the provider is ready to perform sync operations
|
||||
* @returns True if the provider is authenticated and ready
|
||||
*/
|
||||
isReady(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Gets authentication helper for initiating auth flows
|
||||
* @returns Auth helper object or undefined if not supported
|
||||
*/
|
||||
getAuthHelper?(): Promise<SyncProviderAuthHelper>;
|
||||
|
||||
/**
|
||||
* Updates the provider's private configuration
|
||||
* @param privateCfg New configuration to store
|
||||
*/
|
||||
setPrivateCfg(privateCfg: PrivateCfgByProviderId<PID>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clears authentication credentials while preserving non-auth config (e.g., encryptKey).
|
||||
* Called when auth errors occur to ensure re-auth flow can proceed.
|
||||
*/
|
||||
clearAuthCredentials?(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `SyncProviderBase` for generic provider references
|
||||
* or `FileSyncProvider` for file-based providers. Kept as alias for backward compatibility.
|
||||
*/
|
||||
export type SyncProviderServiceInterface<PID extends SyncProviderId> =
|
||||
FileSyncProvider<PID>;
|
||||
|
||||
/**
|
||||
* Type guard to check if a provider supports file-based sync operations.
|
||||
*/
|
||||
export const isFileSyncProvider = (
|
||||
provider: SyncProviderBase<SyncProviderId>,
|
||||
): provider is FileSyncProvider<SyncProviderId> => {
|
||||
return (
|
||||
'getFileRev' in provider &&
|
||||
typeof (provider as Record<string, unknown>).getFileRev === 'function'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Response for file revision operations
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -211,9 +211,7 @@ describe('SuperSyncProvider', () => {
|
|||
expect(mockPrivateCfgStore.load).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should call privateCfg.load for each server seq operation (relies on SyncCredentialStore caching)', async () => {
|
||||
// Note: We removed redundant caching in SuperSyncProvider since SyncCredentialStore
|
||||
// already has its own in-memory caching.
|
||||
it('should call privateCfg.load only once for server seq operations (result is cached)', async () => {
|
||||
mockPrivateCfgStore.load.and.returnValue(Promise.resolve(testConfig));
|
||||
localStorageSpy.getItem.and.returnValue('10');
|
||||
|
||||
|
|
@ -222,40 +220,8 @@ describe('SuperSyncProvider', () => {
|
|||
await provider.getLastServerSeq();
|
||||
await provider.getLastServerSeq();
|
||||
|
||||
// privateCfg.load is called for each operation, but SyncCredentialStore caches internally
|
||||
expect(mockPrivateCfgStore.load).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file operations (not supported)', () => {
|
||||
it('should throw error for getFileRev', async () => {
|
||||
await expectAsync(provider.getFileRev('/path', null)).toBeRejectedWithError(
|
||||
'SuperSync uses operation-based sync only. File operations not supported.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for downloadFile', async () => {
|
||||
await expectAsync(provider.downloadFile('/path')).toBeRejectedWithError(
|
||||
'SuperSync uses operation-based sync only. File operations not supported.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for uploadFile', async () => {
|
||||
await expectAsync(provider.uploadFile('/path', 'data', null)).toBeRejectedWithError(
|
||||
'SuperSync uses operation-based sync only. File operations not supported.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for removeFile', async () => {
|
||||
await expectAsync(provider.removeFile('/path')).toBeRejectedWithError(
|
||||
'SuperSync uses operation-based sync only. File operations not supported.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for listFiles', async () => {
|
||||
await expectAsync(provider.listFiles('/path')).toBeRejectedWithError(
|
||||
'SuperSync uses operation-based sync only. File operations not supported.',
|
||||
);
|
||||
// privateCfg.load is called only once because _getServerSeqKey caches the result
|
||||
expect(mockPrivateCfgStore.load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { Capacitor } from '@capacitor/core';
|
||||
import { SyncProviderId } from '../provider.const';
|
||||
import {
|
||||
SyncProviderServiceInterface,
|
||||
FileRevResponse,
|
||||
FileDownloadResponse,
|
||||
SyncProviderBase,
|
||||
OperationSyncCapable,
|
||||
SyncOperation,
|
||||
OpUploadResponse,
|
||||
|
|
@ -56,7 +54,7 @@ const SUPERSYNC_REQUEST_TIMEOUT_MS = 75000;
|
|||
*/
|
||||
export class SuperSyncProvider
|
||||
implements
|
||||
SyncProviderServiceInterface<SyncProviderId.SuperSync>,
|
||||
SyncProviderBase<SyncProviderId.SuperSync>,
|
||||
OperationSyncCapable,
|
||||
RestoreCapable
|
||||
{
|
||||
|
|
@ -66,6 +64,7 @@ export class SuperSyncProvider
|
|||
readonly supportsOperationSync = true;
|
||||
|
||||
public privateCfg: SyncCredentialStore<SyncProviderId.SuperSync>;
|
||||
private _cachedServerSeqKey: string | null = null;
|
||||
|
||||
constructor(_basePath?: string) {
|
||||
// basePath is ignored - SuperSync uses operation-based sync only
|
||||
|
|
@ -88,6 +87,7 @@ export class SuperSyncProvider
|
|||
}
|
||||
|
||||
async setPrivateCfg(cfg: SuperSyncPrivateCfg): Promise<void> {
|
||||
this._cachedServerSeqKey = null;
|
||||
await this.privateCfg.setComplete(cfg);
|
||||
}
|
||||
|
||||
|
|
@ -103,46 +103,6 @@ export class SuperSyncProvider
|
|||
}
|
||||
}
|
||||
|
||||
// === File Operations (Not supported - use operation sync instead) ===
|
||||
|
||||
async getFileRev(
|
||||
_targetPath: string,
|
||||
_localRev: string | null,
|
||||
): Promise<FileRevResponse> {
|
||||
throw new Error(
|
||||
'SuperSync uses operation-based sync only. File operations not supported.',
|
||||
);
|
||||
}
|
||||
|
||||
async downloadFile(_targetPath: string): Promise<FileDownloadResponse> {
|
||||
throw new Error(
|
||||
'SuperSync uses operation-based sync only. File operations not supported.',
|
||||
);
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
_targetPath: string,
|
||||
_dataStr: string,
|
||||
_localRev: string | null,
|
||||
_isForceOverwrite?: boolean,
|
||||
): Promise<FileRevResponse> {
|
||||
throw new Error(
|
||||
'SuperSync uses operation-based sync only. File operations not supported.',
|
||||
);
|
||||
}
|
||||
|
||||
async removeFile(_targetPath: string): Promise<void> {
|
||||
throw new Error(
|
||||
'SuperSync uses operation-based sync only. File operations not supported.',
|
||||
);
|
||||
}
|
||||
|
||||
async listFiles(_dirPath: string): Promise<string[]> {
|
||||
throw new Error(
|
||||
'SuperSync uses operation-based sync only. File operations not supported.',
|
||||
);
|
||||
}
|
||||
|
||||
// === Operation Sync Implementation ===
|
||||
|
||||
async uploadOps(
|
||||
|
|
@ -401,6 +361,9 @@ export class SuperSyncProvider
|
|||
* when switching between different accounts or servers.
|
||||
*/
|
||||
private async _getServerSeqKey(): Promise<string> {
|
||||
if (this._cachedServerSeqKey) {
|
||||
return this._cachedServerSeqKey;
|
||||
}
|
||||
// Note: SyncCredentialStore.load() has its own in-memory caching
|
||||
const cfg = await this.privateCfg.load();
|
||||
const baseUrl = cfg?.baseUrl || SUPER_SYNC_DEFAULT_BASE_URL;
|
||||
|
|
@ -413,7 +376,8 @@ export class SuperSyncProvider
|
|||
.split('')
|
||||
.reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) | 0, 0)
|
||||
.toString(16);
|
||||
return `${LAST_SERVER_SEQ_KEY_PREFIX}${hash}`;
|
||||
this._cachedServerSeqKey = `${LAST_SERVER_SEQ_KEY_PREFIX}${hash}`;
|
||||
return this._cachedServerSeqKey;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -482,21 +446,89 @@ export class SuperSyncProvider
|
|||
path: string,
|
||||
options: RequestInit,
|
||||
): Promise<T> {
|
||||
const startTime = Date.now();
|
||||
const baseUrl = this._resolveBaseUrl(cfg);
|
||||
const url = `${baseUrl}${path}`;
|
||||
const sanitizedToken = this._sanitizeToken(cfg.accessToken);
|
||||
|
||||
// On native platforms (Android/iOS), use CapacitorHttp for consistent behavior
|
||||
if (this.isNativePlatform) {
|
||||
return this._fetchApiNative<T>(cfg, path, options.method || 'GET', startTime);
|
||||
return this._doNativeFetch<T>(cfg, path, options.method || 'GET');
|
||||
}
|
||||
|
||||
const headers = new Headers(options.headers as HeadersInit);
|
||||
headers.set('Content-Type', 'application/json');
|
||||
headers.set('Authorization', `Bearer ${sanitizedToken}`);
|
||||
|
||||
// Add timeout using AbortController
|
||||
return this._doWebFetch<T>(url, path, headers, { method: options.method || 'GET' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a gzip-compressed request body.
|
||||
* Used for large payloads like snapshot uploads.
|
||||
*/
|
||||
private async _fetchApiCompressed<T>(
|
||||
cfg: SuperSyncPrivateCfg,
|
||||
path: string,
|
||||
compressedBody: Uint8Array,
|
||||
): Promise<T> {
|
||||
const baseUrl = this._resolveBaseUrl(cfg);
|
||||
const url = `${baseUrl}${path}`;
|
||||
const sanitizedToken = this._sanitizeToken(cfg.accessToken);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', 'application/json');
|
||||
headers.set('Content-Encoding', 'gzip');
|
||||
headers.set('Authorization', `Bearer ${sanitizedToken}`);
|
||||
|
||||
return this._doWebFetch<T>(url, path, headers, {
|
||||
method: 'POST',
|
||||
body: new Blob([compressedBody as BlobPart]),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a gzip-compressed request body from native platforms (Android/iOS)
|
||||
* with retry logic for transient network errors.
|
||||
* Android WebView's fetch() corrupts binary Uint8Array bodies, and iOS WebKit
|
||||
* may have similar issues in Capacitor context. We use CapacitorHttp with
|
||||
* base64-encoded gzip data instead.
|
||||
*/
|
||||
private async _fetchApiCompressedNative<T>(
|
||||
cfg: SuperSyncPrivateCfg,
|
||||
path: string,
|
||||
jsonPayload: string,
|
||||
): Promise<T> {
|
||||
const base64Gzip = await compressWithGzipToString(jsonPayload);
|
||||
|
||||
SyncLog.debug(this.logLabel, '_fetchApiCompressedNative', {
|
||||
path,
|
||||
originalSize: jsonPayload.length,
|
||||
compressedBase64Size: base64Gzip.length,
|
||||
});
|
||||
|
||||
const extraHeaders: Record<string, string> = {};
|
||||
extraHeaders['Content-Encoding'] = 'gzip';
|
||||
extraHeaders['Content-Transfer-Encoding'] = 'base64';
|
||||
|
||||
return this._doNativeFetch<T>(cfg, path, 'POST', {
|
||||
data: base64Gzip,
|
||||
extraHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
// === Shared HTTP helpers ===
|
||||
|
||||
/**
|
||||
* Shared web fetch with AbortController timeout, error handling,
|
||||
* and slow-request logging.
|
||||
*/
|
||||
private async _doWebFetch<T>(
|
||||
url: string,
|
||||
path: string,
|
||||
headers: Headers,
|
||||
options: { method: string; body?: BodyInit },
|
||||
): Promise<T> {
|
||||
const startTime = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), SUPERSYNC_REQUEST_TIMEOUT_MS);
|
||||
|
||||
|
|
@ -558,21 +590,23 @@ export class SuperSyncProvider
|
|||
}
|
||||
|
||||
/**
|
||||
* Handles API requests on native platforms (Android/iOS) using CapacitorHttp
|
||||
* with retry logic for transient network errors.
|
||||
* Shared native fetch using CapacitorHttp with retry logic,
|
||||
* error handling, and slow-request logging.
|
||||
*/
|
||||
private async _fetchApiNative<T>(
|
||||
private async _doNativeFetch<T>(
|
||||
cfg: SuperSyncPrivateCfg,
|
||||
path: string,
|
||||
method: string,
|
||||
startTime: number,
|
||||
requestData?: { data: string; extraHeaders?: Record<string, string> },
|
||||
): Promise<T> {
|
||||
const startTime = Date.now();
|
||||
const baseUrl = this._resolveBaseUrl(cfg);
|
||||
const url = `${baseUrl}${path}`;
|
||||
const sanitizedToken = this._sanitizeToken(cfg.accessToken);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${sanitizedToken}`,
|
||||
...requestData?.extraHeaders,
|
||||
};
|
||||
headers['Content-Type'] = 'application/json';
|
||||
|
||||
|
|
@ -582,157 +616,7 @@ export class SuperSyncProvider
|
|||
url,
|
||||
method,
|
||||
headers,
|
||||
connectTimeout: 10000,
|
||||
readTimeout: SUPERSYNC_REQUEST_TIMEOUT_MS,
|
||||
},
|
||||
this.logLabel,
|
||||
);
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const errorData =
|
||||
typeof response.data === 'string'
|
||||
? response.data
|
||||
: JSON.stringify(response.data);
|
||||
// Check for auth failure FIRST before throwing generic error
|
||||
this._checkHttpStatus(response.status, errorData);
|
||||
throw new Error(`SuperSync API error: ${response.status} - ${errorData}`);
|
||||
}
|
||||
|
||||
// Log slow requests
|
||||
const duration = Date.now() - startTime;
|
||||
if (duration > 30000) {
|
||||
SyncLog.warn(this.logLabel, `Slow SuperSync request detected (native)`, {
|
||||
path,
|
||||
durationMs: duration,
|
||||
durationSec: (duration / 1000).toFixed(1),
|
||||
});
|
||||
}
|
||||
|
||||
return response.data as T;
|
||||
} catch (error) {
|
||||
this._handleNativeRequestError(error, path, startTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a gzip-compressed request body.
|
||||
* Used for large payloads like snapshot uploads.
|
||||
*/
|
||||
private async _fetchApiCompressed<T>(
|
||||
cfg: SuperSyncPrivateCfg,
|
||||
path: string,
|
||||
compressedBody: Uint8Array,
|
||||
): Promise<T> {
|
||||
const startTime = Date.now();
|
||||
const baseUrl = this._resolveBaseUrl(cfg);
|
||||
const url = `${baseUrl}${path}`;
|
||||
const sanitizedToken = this._sanitizeToken(cfg.accessToken);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', 'application/json');
|
||||
headers.set('Content-Encoding', 'gzip');
|
||||
headers.set('Authorization', `Bearer ${sanitizedToken}`);
|
||||
|
||||
// Add timeout using AbortController
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), SUPERSYNC_REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: new Blob([compressedBody as BlobPart]),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
clearTimeout(timeoutId);
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
// Check for auth failure FIRST before throwing generic error
|
||||
this._checkHttpStatus(response.status, errorText);
|
||||
throw new Error(
|
||||
`SuperSync API error: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
// CRITICAL: Read response body BEFORE clearing timeout
|
||||
const data = (await response.json()) as T;
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Log slow requests
|
||||
const duration = Date.now() - startTime;
|
||||
if (duration > 30000) {
|
||||
SyncLog.warn(this.logLabel, `Slow SuperSync request detected`, {
|
||||
path,
|
||||
durationMs: duration,
|
||||
durationSec: (duration / 1000).toFixed(1),
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
SyncLog.error(this.logLabel, `SuperSync request timeout`, {
|
||||
path,
|
||||
durationMs: duration,
|
||||
timeoutMs: SUPERSYNC_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
throw new Error(
|
||||
`SuperSync request timeout after ${SUPERSYNC_REQUEST_TIMEOUT_MS / 1000}s: ${path}`,
|
||||
);
|
||||
}
|
||||
|
||||
SyncLog.error(this.logLabel, `SuperSync request failed`, {
|
||||
path,
|
||||
durationMs: duration,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a gzip-compressed request body from native platforms (Android/iOS)
|
||||
* with retry logic for transient network errors.
|
||||
* Android WebView's fetch() corrupts binary Uint8Array bodies, and iOS WebKit
|
||||
* may have similar issues in Capacitor context. We use CapacitorHttp with
|
||||
* base64-encoded gzip data instead.
|
||||
*/
|
||||
private async _fetchApiCompressedNative<T>(
|
||||
cfg: SuperSyncPrivateCfg,
|
||||
path: string,
|
||||
jsonPayload: string,
|
||||
): Promise<T> {
|
||||
const startTime = Date.now();
|
||||
const base64Gzip = await compressWithGzipToString(jsonPayload);
|
||||
const baseUrl = this._resolveBaseUrl(cfg);
|
||||
const url = `${baseUrl}${path}`;
|
||||
const sanitizedToken = this._sanitizeToken(cfg.accessToken);
|
||||
|
||||
SyncLog.debug(this.logLabel, '_fetchApiCompressedNative', {
|
||||
path,
|
||||
originalSize: jsonPayload.length,
|
||||
compressedBase64Size: base64Gzip.length,
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${sanitizedToken}`,
|
||||
};
|
||||
// HTTP headers with hyphens don't match naming convention - use bracket notation
|
||||
headers['Content-Type'] = 'application/json';
|
||||
headers['Content-Encoding'] = 'gzip';
|
||||
headers['Content-Transfer-Encoding'] = 'base64';
|
||||
|
||||
try {
|
||||
const response = await executeNativeRequestWithRetry(
|
||||
{
|
||||
url,
|
||||
method: 'POST',
|
||||
headers,
|
||||
data: base64Gzip,
|
||||
data: requestData?.data,
|
||||
connectTimeout: 10000,
|
||||
readTimeout: SUPERSYNC_REQUEST_TIMEOUT_MS,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { Subject } from 'rxjs';
|
||||
import { WrappedProviderService } from './wrapped-provider.service';
|
||||
import { SyncProviderManager } from './provider-manager.service';
|
||||
import { FileBasedSyncAdapterService } from './file-based/file-based-sync-adapter.service';
|
||||
|
|
@ -9,6 +10,7 @@ describe('WrappedProviderService', () => {
|
|||
let service: WrappedProviderService;
|
||||
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
|
||||
let mockFileBasedAdapter: jasmine.SpyObj<FileBasedSyncAdapterService>;
|
||||
let providerConfigChanged$: Subject<void>;
|
||||
|
||||
const createMockProvider = (
|
||||
id: SyncProviderId,
|
||||
|
|
@ -39,9 +41,13 @@ describe('WrappedProviderService', () => {
|
|||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockProviderManager = jasmine.createSpyObj('SyncProviderManager', [
|
||||
'getEncryptAndCompressCfg',
|
||||
]);
|
||||
providerConfigChanged$ = new Subject<void>();
|
||||
|
||||
mockProviderManager = jasmine.createSpyObj(
|
||||
'SyncProviderManager',
|
||||
['getEncryptAndCompressCfg'],
|
||||
{ providerConfigChanged$ },
|
||||
);
|
||||
mockProviderManager.getEncryptAndCompressCfg.and.returnValue({
|
||||
isEncrypt: true,
|
||||
isCompress: true,
|
||||
|
|
@ -167,4 +173,25 @@ describe('WrappedProviderService', () => {
|
|||
expect(mockFileBasedAdapter.createAdapter).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-invalidation on config change', () => {
|
||||
it('should auto-clear cache when providerConfigChanged$ emits', async () => {
|
||||
const dropboxProvider = createMockProvider(SyncProviderId.Dropbox, false);
|
||||
const mockAdapter1 = createMockSyncCapableAdapter();
|
||||
const mockAdapter2 = createMockSyncCapableAdapter();
|
||||
mockFileBasedAdapter.createAdapter.and.returnValues(mockAdapter1, mockAdapter2);
|
||||
|
||||
// First call - creates and caches adapter1
|
||||
const result1 = await service.getOperationSyncCapable(dropboxProvider);
|
||||
expect(result1).toBe(mockAdapter1);
|
||||
|
||||
// Simulate config change
|
||||
providerConfigChanged$.next();
|
||||
|
||||
// Next call should create a new adapter (cache was auto-cleared)
|
||||
const result2 = await service.getOperationSyncCapable(dropboxProvider);
|
||||
expect(result2).toBe(mockAdapter2);
|
||||
expect(mockFileBasedAdapter.createAdapter).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { DestroyRef, inject, Injectable } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { SyncProviderId } from './provider.const';
|
||||
import { SyncProviderServiceInterface, OperationSyncCapable } from './provider.interface';
|
||||
import {
|
||||
SyncProviderBase,
|
||||
FileSyncProvider,
|
||||
OperationSyncCapable,
|
||||
} from './provider.interface';
|
||||
import { FileBasedSyncAdapterService } from './file-based/file-based-sync-adapter.service';
|
||||
import { SyncProviderManager } from './provider-manager.service';
|
||||
import { isOperationSyncCapable, isFileBasedProvider } from '../sync/operation-sync.util';
|
||||
|
|
@ -36,10 +41,23 @@ import { OpLog } from '../../core/log';
|
|||
export class WrappedProviderService {
|
||||
private _fileBasedAdapter = inject(FileBasedSyncAdapterService);
|
||||
private _providerManager = inject(SyncProviderManager);
|
||||
private _destroyRef = inject(DestroyRef);
|
||||
|
||||
/** Cache of wrapped adapters per provider ID */
|
||||
private _cache = new Map<string, OperationSyncCapable>();
|
||||
|
||||
constructor() {
|
||||
// Auto-invalidate cache when provider config changes
|
||||
this._providerManager.providerConfigChanged$
|
||||
.pipe(takeUntilDestroyed(this._destroyRef))
|
||||
.subscribe(() => {
|
||||
this._cache.clear();
|
||||
OpLog.normal(
|
||||
'WrappedProviderService: Cache auto-invalidated due to config change',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an OperationSyncCapable version of the provider.
|
||||
*
|
||||
|
|
@ -47,7 +65,7 @@ export class WrappedProviderService {
|
|||
* @returns OperationSyncCapable provider, or null if provider doesn't support sync
|
||||
*/
|
||||
async getOperationSyncCapable(
|
||||
provider: SyncProviderServiceInterface<SyncProviderId> | null,
|
||||
provider: SyncProviderBase<SyncProviderId> | null,
|
||||
): Promise<OperationSyncCapable | null> {
|
||||
if (!provider) {
|
||||
return null;
|
||||
|
|
@ -60,7 +78,7 @@ export class WrappedProviderService {
|
|||
|
||||
// File-based providers need wrapping
|
||||
if (isFileBasedProvider(provider)) {
|
||||
return this._getOrCreateAdapter(provider);
|
||||
return this._getOrCreateAdapter(provider as FileSyncProvider<SyncProviderId>);
|
||||
}
|
||||
|
||||
// Unknown provider type
|
||||
|
|
@ -72,7 +90,7 @@ export class WrappedProviderService {
|
|||
* Gets or creates a wrapped adapter for a file-based provider.
|
||||
*/
|
||||
private async _getOrCreateAdapter(
|
||||
provider: SyncProviderServiceInterface<SyncProviderId>,
|
||||
provider: FileSyncProvider<SyncProviderId>,
|
||||
): Promise<OperationSyncCapable> {
|
||||
const cached = this._cache.get(provider.id);
|
||||
if (cached) {
|
||||
|
|
@ -103,7 +121,8 @@ export class WrappedProviderService {
|
|||
|
||||
/**
|
||||
* Clears the adapter cache.
|
||||
* Call this when encryption settings change to force adapter recreation.
|
||||
* @deprecated Cache is now auto-invalidated when provider config changes via SyncProviderManager.
|
||||
* Kept as an escape hatch for edge cases.
|
||||
*/
|
||||
clearCache(): void {
|
||||
this._cache.clear();
|
||||
|
|
|
|||
|
|
@ -9,12 +9,6 @@ import {
|
|||
mockEncryptBatch,
|
||||
mockDecryptBatch,
|
||||
} from '../testing/helpers/mock-encryption.helper';
|
||||
import {
|
||||
ENCRYPT_FN,
|
||||
DECRYPT_FN,
|
||||
ENCRYPT_BATCH_FN,
|
||||
DECRYPT_BATCH_FN,
|
||||
} from '../encryption/encryption.token';
|
||||
|
||||
describe('OperationEncryptionService', () => {
|
||||
let service: OperationEncryptionService;
|
||||
|
|
@ -36,16 +30,16 @@ describe('OperationEncryptionService', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
OperationEncryptionService,
|
||||
// Use fast mock encryption instead of real Argon2id (saves ~500ms per test)
|
||||
{ provide: ENCRYPT_FN, useValue: mockEncrypt },
|
||||
{ provide: DECRYPT_FN, useValue: mockDecrypt },
|
||||
{ provide: ENCRYPT_BATCH_FN, useValue: mockEncryptBatch },
|
||||
{ provide: DECRYPT_BATCH_FN, useValue: mockDecryptBatch },
|
||||
],
|
||||
providers: [OperationEncryptionService],
|
||||
});
|
||||
service = TestBed.inject(OperationEncryptionService);
|
||||
|
||||
// Use fast mock encryption instead of real Argon2id (saves ~500ms per test)
|
||||
// Spy on private properties via type cast to avoid slow real encryption
|
||||
spyOn(service as any, '_encrypt').and.callFake(mockEncrypt);
|
||||
spyOn(service as any, '_decrypt').and.callFake(mockDecrypt);
|
||||
spyOn(service as any, '_encryptBatch').and.callFake(mockEncryptBatch);
|
||||
spyOn(service as any, '_decryptBatch').and.callFake(mockDecryptBatch);
|
||||
});
|
||||
|
||||
describe('encryptOperation', () => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
ENCRYPT_FN,
|
||||
DECRYPT_FN,
|
||||
ENCRYPT_BATCH_FN,
|
||||
DECRYPT_BATCH_FN,
|
||||
} from '../encryption/encryption.token';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { encrypt, decrypt, encryptBatch, decryptBatch } from '../encryption/encryption';
|
||||
import { SyncOperation } from '../sync-providers/provider.interface';
|
||||
import { DecryptError } from '../core/errors/sync-errors';
|
||||
|
||||
|
|
@ -21,10 +16,11 @@ import { DecryptError } from '../core/errors/sync-errors';
|
|||
providedIn: 'root',
|
||||
})
|
||||
export class OperationEncryptionService {
|
||||
private readonly _encrypt = inject(ENCRYPT_FN);
|
||||
private readonly _decrypt = inject(DECRYPT_FN);
|
||||
private readonly _encryptBatch = inject(ENCRYPT_BATCH_FN);
|
||||
private readonly _decryptBatch = inject(DECRYPT_BATCH_FN);
|
||||
// Exposed as properties so tests can spy on them without DI tokens
|
||||
private _encrypt = encrypt;
|
||||
private _decrypt = decrypt;
|
||||
private _encryptBatch = encryptBatch;
|
||||
private _decryptBatch = decryptBatch;
|
||||
|
||||
/**
|
||||
* Encrypts the payload of a SyncOperation.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
import { OpLog } from '../../core/log';
|
||||
import {
|
||||
OperationSyncCapable,
|
||||
SyncProviderServiceInterface,
|
||||
SyncProviderBase,
|
||||
} from '../sync-providers/provider.interface';
|
||||
import { SyncProviderId } from '../sync-providers/provider.const';
|
||||
import { OperationLogUploadService, UploadResult } from './operation-log-upload.service';
|
||||
|
|
@ -30,6 +30,10 @@ import {
|
|||
} from './rejected-ops-handler.service';
|
||||
import { SyncHydrationService } from '../persistence/sync-hydration.service';
|
||||
import { SyncImportConflictDialogService } from './sync-import-conflict-dialog.service';
|
||||
import {
|
||||
SyncImportConflictData,
|
||||
SyncImportConflictResolution,
|
||||
} from './dialog-sync-import-conflict/dialog-sync-import-conflict.component';
|
||||
import { SyncProviderManager } from '../sync-providers/provider-manager.service';
|
||||
import { getDefaultMainModelData } from '../model/model-config';
|
||||
import { loadAllData } from '../../root-store/meta/load-all-data.action';
|
||||
|
|
@ -192,6 +196,15 @@ export class OperationLogSyncService {
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there is any meaningful user data — either in the pending ops
|
||||
* or already in the NgRx store. This combines both checks that are always
|
||||
* used together to decide whether a conflict dialog is needed.
|
||||
*/
|
||||
private _hasAnyMeaningfulData(pendingOps: OperationLogEntry[]): boolean {
|
||||
return this._hasMeaningfulPendingOps(pendingOps) || this._hasMeaningfulLocalData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any of the given ops represent meaningful user data.
|
||||
* Meaningful = TASK/PROJECT/TAG/NOTE creates/updates/deletes, or full-state ops.
|
||||
|
|
@ -303,56 +316,28 @@ export class OperationLogSyncService {
|
|||
piggybackedImport.syncImportReason === 'PASSWORD_CHANGED' &&
|
||||
!hasMeaningfulPending;
|
||||
|
||||
if (
|
||||
!isEncryptionOnlyChange &&
|
||||
(hasMeaningfulPending || this._hasMeaningfulLocalData())
|
||||
) {
|
||||
if (!isEncryptionOnlyChange && this._hasAnyMeaningfulData(pendingOps)) {
|
||||
OpLog.warn(
|
||||
`OperationLogSyncService: Piggybacked SYNC_IMPORT from client ${piggybackedImport.clientId} ` +
|
||||
`with ${pendingOps.length} pending local ops. Showing conflict dialog.`,
|
||||
);
|
||||
|
||||
const resolution =
|
||||
await this.syncImportConflictDialogService.showConflictDialog({
|
||||
const resolution = await this._handleSyncImportConflict(
|
||||
syncProvider,
|
||||
{
|
||||
filteredOpCount: pendingOps.length,
|
||||
localImportTimestamp: piggybackedImport.timestamp ?? Date.now(),
|
||||
syncImportReason: piggybackedImport.syncImportReason,
|
||||
scenario: 'INCOMING_IMPORT',
|
||||
});
|
||||
|
||||
switch (resolution) {
|
||||
case 'USE_LOCAL':
|
||||
OpLog.normal(
|
||||
'OperationLogSyncService: User chose USE_LOCAL for piggybacked SYNC_IMPORT. Force uploading local state.',
|
||||
);
|
||||
await this.forceUploadLocalState(syncProvider);
|
||||
return {
|
||||
...result,
|
||||
localWinOpsCreated: 0,
|
||||
permanentRejectionCount: 0,
|
||||
};
|
||||
case 'USE_REMOTE':
|
||||
OpLog.normal(
|
||||
'OperationLogSyncService: User chose USE_REMOTE for piggybacked SYNC_IMPORT. Force downloading remote state.',
|
||||
);
|
||||
await this.forceDownloadRemoteState(syncProvider);
|
||||
return {
|
||||
...result,
|
||||
localWinOpsCreated: 0,
|
||||
permanentRejectionCount: 0,
|
||||
};
|
||||
case 'CANCEL':
|
||||
default:
|
||||
OpLog.normal(
|
||||
'OperationLogSyncService: User cancelled piggybacked SYNC_IMPORT conflict resolution.',
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
localWinOpsCreated: 0,
|
||||
permanentRejectionCount: 0,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
'OperationLogSyncService (piggybacked SYNC_IMPORT)',
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
localWinOpsCreated: 0,
|
||||
permanentRejectionCount: 0,
|
||||
cancelled: resolution === 'CANCEL',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -457,8 +442,7 @@ export class OperationLogSyncService {
|
|||
// The store check catches provider-switch scenarios: user switches from
|
||||
// SuperSync→Dropbox, only has a config-change op (not "meaningful"), but the
|
||||
// store is full of real data that would be overwritten by old Dropbox state.
|
||||
const hasMeaningfulUserData =
|
||||
this._hasMeaningfulPendingOps(unsyncedOps) || this._hasMeaningfulLocalData();
|
||||
const hasMeaningfulUserData = this._hasAnyMeaningfulData(unsyncedOps);
|
||||
|
||||
if (hasMeaningfulUserData) {
|
||||
// Client has meaningful user data - show conflict dialog
|
||||
|
|
@ -676,55 +660,28 @@ export class OperationLogSyncService {
|
|||
incomingSyncImport.syncImportReason === 'PASSWORD_CHANGED' &&
|
||||
!hasMeaningfulPending;
|
||||
|
||||
if (
|
||||
!isEncryptionOnlyChange &&
|
||||
(hasMeaningfulPending || this._hasMeaningfulLocalData())
|
||||
) {
|
||||
if (!isEncryptionOnlyChange && this._hasAnyMeaningfulData(pendingLocalOps)) {
|
||||
OpLog.warn(
|
||||
`OperationLogSyncService: Incoming SYNC_IMPORT from client ${incomingSyncImport.clientId} ` +
|
||||
`with ${pendingLocalOps.length} pending local ops. Showing conflict dialog.`,
|
||||
);
|
||||
|
||||
const resolution = await this.syncImportConflictDialogService.showConflictDialog({
|
||||
filteredOpCount: pendingLocalOps.length,
|
||||
localImportTimestamp: incomingSyncImport.timestamp ?? Date.now(),
|
||||
syncImportReason: incomingSyncImport.syncImportReason,
|
||||
scenario: 'INCOMING_IMPORT',
|
||||
});
|
||||
|
||||
switch (resolution) {
|
||||
case 'USE_LOCAL':
|
||||
OpLog.normal(
|
||||
'OperationLogSyncService: User chose USE_LOCAL. Force uploading local state.',
|
||||
);
|
||||
await this.forceUploadLocalState(syncProvider);
|
||||
return {
|
||||
serverMigrationHandled: false,
|
||||
localWinOpsCreated: 0,
|
||||
newOpsCount: 0,
|
||||
};
|
||||
case 'USE_REMOTE':
|
||||
OpLog.normal(
|
||||
'OperationLogSyncService: User chose USE_REMOTE. Discarding local ops and downloading remote state.',
|
||||
);
|
||||
await this.forceDownloadRemoteState(syncProvider);
|
||||
return {
|
||||
serverMigrationHandled: false,
|
||||
localWinOpsCreated: 0,
|
||||
newOpsCount: 0,
|
||||
};
|
||||
case 'CANCEL':
|
||||
default:
|
||||
OpLog.normal(
|
||||
'OperationLogSyncService: User cancelled incoming SYNC_IMPORT conflict resolution.',
|
||||
);
|
||||
return {
|
||||
serverMigrationHandled: false,
|
||||
localWinOpsCreated: 0,
|
||||
newOpsCount: 0,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
const resolution = await this._handleSyncImportConflict(
|
||||
syncProvider,
|
||||
{
|
||||
filteredOpCount: pendingLocalOps.length,
|
||||
localImportTimestamp: incomingSyncImport.timestamp ?? Date.now(),
|
||||
syncImportReason: incomingSyncImport.syncImportReason,
|
||||
scenario: 'INCOMING_IMPORT',
|
||||
},
|
||||
'OperationLogSyncService (incoming SYNC_IMPORT)',
|
||||
);
|
||||
return {
|
||||
serverMigrationHandled: false,
|
||||
localWinOpsCreated: 0,
|
||||
newOpsCount: 0,
|
||||
cancelled: resolution === 'CANCEL',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -751,46 +708,22 @@ export class OperationLogSyncService {
|
|||
`Showing conflict resolution dialog (local import detected).`,
|
||||
);
|
||||
|
||||
const resolution = await this.syncImportConflictDialogService.showConflictDialog({
|
||||
filteredOpCount: processResult.filteredOpCount,
|
||||
localImportTimestamp: processResult.filteringImport?.timestamp ?? Date.now(),
|
||||
syncImportReason: processResult.filteringImport?.syncImportReason,
|
||||
scenario: 'LOCAL_IMPORT_FILTERS_REMOTE',
|
||||
});
|
||||
|
||||
switch (resolution) {
|
||||
case 'USE_LOCAL':
|
||||
OpLog.normal(
|
||||
'OperationLogSyncService: User chose USE_LOCAL. Force uploading local state.',
|
||||
);
|
||||
await this.forceUploadLocalState(syncProvider);
|
||||
return {
|
||||
serverMigrationHandled: false,
|
||||
localWinOpsCreated: 0,
|
||||
newOpsCount: 0,
|
||||
};
|
||||
case 'USE_REMOTE':
|
||||
OpLog.normal(
|
||||
'OperationLogSyncService: User chose USE_REMOTE. Force downloading remote state.',
|
||||
);
|
||||
await this.forceDownloadRemoteState(syncProvider);
|
||||
return {
|
||||
serverMigrationHandled: false,
|
||||
localWinOpsCreated: 0,
|
||||
newOpsCount: 0,
|
||||
};
|
||||
case 'CANCEL':
|
||||
default:
|
||||
OpLog.normal(
|
||||
'OperationLogSyncService: User cancelled sync import conflict resolution.',
|
||||
);
|
||||
return {
|
||||
serverMigrationHandled: false,
|
||||
localWinOpsCreated: 0,
|
||||
newOpsCount: 0,
|
||||
cancelled: true,
|
||||
};
|
||||
}
|
||||
const resolution = await this._handleSyncImportConflict(
|
||||
syncProvider,
|
||||
{
|
||||
filteredOpCount: processResult.filteredOpCount,
|
||||
localImportTimestamp: processResult.filteringImport?.timestamp ?? Date.now(),
|
||||
syncImportReason: processResult.filteringImport?.syncImportReason,
|
||||
scenario: 'LOCAL_IMPORT_FILTERS_REMOTE',
|
||||
},
|
||||
'OperationLogSyncService (local SYNC_IMPORT filters remote)',
|
||||
);
|
||||
return {
|
||||
serverMigrationHandled: false,
|
||||
localWinOpsCreated: 0,
|
||||
newOpsCount: 0,
|
||||
cancelled: resolution === 'CANCEL',
|
||||
};
|
||||
} else if (
|
||||
processResult.allOpsFilteredBySyncImport &&
|
||||
processResult.filteredOpCount > 0
|
||||
|
|
@ -832,6 +765,44 @@ export class OperationLogSyncService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the SYNC_IMPORT conflict dialog and executes the user's chosen action.
|
||||
*
|
||||
* This consolidates the repeated dialog + switch pattern used when a SYNC_IMPORT
|
||||
* conflicts with local data (piggybacked upload, incoming download, or local
|
||||
* import filtering remote ops).
|
||||
*
|
||||
* @param syncProvider - The sync provider to use for force upload/download
|
||||
* @param dialogData - Data passed to the conflict dialog
|
||||
* @param logPrefix - Prefix for log messages to identify the calling context
|
||||
* @returns The user's resolution choice after the action has been executed
|
||||
*/
|
||||
private async _handleSyncImportConflict(
|
||||
syncProvider: OperationSyncCapable,
|
||||
dialogData: SyncImportConflictData,
|
||||
logPrefix: string,
|
||||
): Promise<SyncImportConflictResolution> {
|
||||
const resolution =
|
||||
await this.syncImportConflictDialogService.showConflictDialog(dialogData);
|
||||
|
||||
switch (resolution) {
|
||||
case 'USE_LOCAL':
|
||||
OpLog.normal(`${logPrefix}: User chose USE_LOCAL. Force uploading local state.`);
|
||||
await this.forceUploadLocalState(syncProvider);
|
||||
return 'USE_LOCAL';
|
||||
case 'USE_REMOTE':
|
||||
OpLog.normal(
|
||||
`${logPrefix}: User chose USE_REMOTE. Force downloading remote state.`,
|
||||
);
|
||||
await this.forceDownloadRemoteState(syncProvider);
|
||||
return 'USE_REMOTE';
|
||||
case 'CANCEL':
|
||||
default:
|
||||
OpLog.normal(`${logPrefix}: User cancelled SYNC_IMPORT conflict resolution.`);
|
||||
return 'CANCEL';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a confirmation dialog for fresh client sync.
|
||||
* Uses synchronous window.confirm() to prevent race conditions where
|
||||
|
|
@ -1117,10 +1088,8 @@ export class OperationLogSyncService {
|
|||
*/
|
||||
private _isSyncProviderWithConfig(
|
||||
provider: OperationSyncCapable,
|
||||
): provider is OperationSyncCapable & SyncProviderServiceInterface<SyncProviderId> {
|
||||
const providerWithCfg = provider as Partial<
|
||||
SyncProviderServiceInterface<SyncProviderId>
|
||||
>;
|
||||
): provider is OperationSyncCapable & SyncProviderBase<SyncProviderId> {
|
||||
const providerWithCfg = provider as Partial<SyncProviderBase<SyncProviderId>>;
|
||||
return (
|
||||
typeof providerWithCfg.privateCfg?.load === 'function' &&
|
||||
typeof providerWithCfg.setPrivateCfg === 'function'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ActionType, OpType, Operation } from '../core/operation.types';
|
||||
import {
|
||||
SyncProviderServiceInterface,
|
||||
SyncProviderBase,
|
||||
OperationSyncCapable,
|
||||
SyncOperation,
|
||||
} from '../sync-providers/provider.interface';
|
||||
|
|
@ -18,8 +18,8 @@ const FILE_BASED_PROVIDER_IDS: Set<SyncProviderId> = new Set([
|
|||
* This is for providers like SuperSync that have a dedicated API endpoint.
|
||||
*/
|
||||
export const isOperationSyncCapable = (
|
||||
provider: SyncProviderServiceInterface<SyncProviderId>,
|
||||
): provider is SyncProviderServiceInterface<SyncProviderId> & OperationSyncCapable => {
|
||||
provider: SyncProviderBase<SyncProviderId>,
|
||||
): provider is SyncProviderBase<SyncProviderId> & OperationSyncCapable => {
|
||||
return (
|
||||
'supportsOperationSync' in provider &&
|
||||
(provider as unknown as OperationSyncCapable).supportsOperationSync === true
|
||||
|
|
@ -31,7 +31,7 @@ export const isOperationSyncCapable = (
|
|||
* File-based providers (WebDAV, Dropbox, LocalFile) use file storage for sync.
|
||||
*/
|
||||
export const isFileBasedProvider = (
|
||||
provider: SyncProviderServiceInterface<SyncProviderId>,
|
||||
provider: SyncProviderBase<SyncProviderId>,
|
||||
): boolean => {
|
||||
return FILE_BASED_PROVIDER_IDS.has(provider.id);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,12 +46,6 @@ import {
|
|||
mockEncryptBatch,
|
||||
mockDecryptBatch,
|
||||
} from '../helpers/mock-encryption.helper';
|
||||
import {
|
||||
ENCRYPT_FN,
|
||||
DECRYPT_FN,
|
||||
ENCRYPT_BATCH_FN,
|
||||
DECRYPT_BATCH_FN,
|
||||
} from '../../encryption/encryption.token';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { SuperSyncStatusService } from '../../sync/super-sync-status.service';
|
||||
import { ServerMigrationService } from '../../sync/server-migration.service';
|
||||
|
|
@ -371,11 +365,6 @@ describe('Service Logic Integration', () => {
|
|||
SchemaMigrationService,
|
||||
RemoteOpsProcessingService,
|
||||
provideMockStore(),
|
||||
// Use fast mock encryption instead of real Argon2id (saves ~500ms per test)
|
||||
{ provide: ENCRYPT_FN, useValue: mockEncrypt },
|
||||
{ provide: DECRYPT_FN, useValue: mockDecrypt },
|
||||
{ provide: ENCRYPT_BATCH_FN, useValue: mockEncryptBatch },
|
||||
{ provide: DECRYPT_BATCH_FN, useValue: mockDecryptBatch },
|
||||
{ provide: ConflictResolutionService, useValue: conflictServiceSpy },
|
||||
{ provide: OperationApplierService, useValue: applierSpy },
|
||||
{ provide: SuperSyncStatusService, useValue: superSyncStatusSpy },
|
||||
|
|
@ -423,6 +412,13 @@ describe('Service Logic Integration', () => {
|
|||
syncService = TestBed.inject(OperationLogSyncService);
|
||||
opLogStore = TestBed.inject(OperationLogStoreService);
|
||||
|
||||
// Use fast mock encryption instead of real Argon2id (saves ~500ms per test)
|
||||
const encryptionService = TestBed.inject(OperationEncryptionService);
|
||||
spyOn(encryptionService as any, '_encrypt').and.callFake(mockEncrypt);
|
||||
spyOn(encryptionService as any, '_decrypt').and.callFake(mockDecrypt);
|
||||
spyOn(encryptionService as any, '_encryptBatch').and.callFake(mockEncryptBatch);
|
||||
spyOn(encryptionService as any, '_decryptBatch').and.callFake(mockDecryptBatch);
|
||||
|
||||
mockProvider = new MockOperationSyncProvider();
|
||||
|
||||
await opLogStore.init();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue