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:
Johannes Millan 2026-03-04 19:01:20 +01:00 committed by GitHub
parent 7c30916d28
commit 49bf056ee0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 802 additions and 1485 deletions

View file

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

View file

@ -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

View file

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

View file

@ -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);

View file

@ -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',
});
});
});
});

View file

@ -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,
};

View file

@ -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',
]);
});
});
});

View file

@ -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 };
}
}

View file

@ -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',
]);
});
});
});

View file

@ -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,
);
}
}

View file

@ -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

View file

@ -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();
}
}
}

View file

@ -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();
}
}

View file

@ -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[]>;

View file

@ -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';

View file

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

View file

@ -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
*/

View file

@ -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);
});
});

View file

@ -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,
},

View file

@ -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);
});
});
});

View file

@ -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();

View file

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

View file

@ -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.

View file

@ -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'

View file

@ -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);
};

View file

@ -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();