refactor(sync): unify encryption state handling

This commit is contained in:
Johannes Millan 2026-01-28 14:48:06 +01:00
parent 7d84a36b4b
commit dbdd0878f5
15 changed files with 653 additions and 192 deletions

View file

@ -198,106 +198,261 @@ export class SuperSyncPage extends BasePage {
// Fill in access token (this field is NOT in the Advanced section)
await this.accessTokenInput.fill(config.accessToken);
// Track if encryption settings were changed (requires fresh sync)
let encryptionSettingsChanged = false;
// Track if this is a fresh setup that needs encryption enabled
// For the FIRST client (Client A), we enable encryption AFTER saving config
// For SUBSEQUENT clients (Client B), we handle the password dialog that appears
// when receiving encrypted data from the server
const needsEncryptionEnabled = config.isEncryptionEnabled && config.password;
// Handle encryption settings if provided
if (config.isEncryptionEnabled !== undefined) {
const isCurrentlyEnabled = await this.disableEncryptionBtn
// Save configuration first (without encryption changes)
// Encryption can only be enabled/disabled after the provider is active
await expect(this.saveBtn).toBeEnabled({ timeout: 5000 });
await this.saveBtn.click();
// Wait for the config dialog to close
// The save() method is async and may take time to complete
// Note: After save, a password dialog might appear if server has encrypted data
// So we check for the config dialog specifically, not just any dialog
await this.page.waitForTimeout(1000);
// Define locators for dialogs we might see
const configDialog = this.page.locator('dialog-sync-initial-cfg');
const passwordDialog = this.page.locator('dialog-enter-encryption-password');
// Wait for config dialog to close OR password dialog to appear
// (password dialog appearing means config dialog closed and sync started)
const configDialogClosed = await Promise.race([
configDialog
.waitFor({ state: 'hidden', timeout: 15000 })
.then(() => 'config_closed'),
passwordDialog
.waitFor({ state: 'visible', timeout: 15000 })
.then(() => 'password_appeared'),
]).catch(() => 'timeout');
if (configDialogClosed === 'timeout') {
// Neither happened - try pressing Escape to close any stuck dialog
console.log(
'[SuperSyncPage] Config dialog did not close after save, trying Escape',
);
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(500);
} else if (configDialogClosed === 'password_appeared') {
console.log('[SuperSyncPage] Password dialog appeared - config dialog closed');
}
// If encryption is needed, handle the flow based on whether server has encrypted data
if (needsEncryptionEnabled && waitForInitialSync) {
// Wait for one of several possible outcomes:
// 1. Password dialog (server has encrypted data, client needs to enter password)
// 2. Fresh client dialog (server has no data or unencrypted data)
// 3. Sync completes (server has no data)
// 4. Sync import conflict dialog
await this.page.waitForTimeout(1000);
// Define locators for all possible dialogs
const passwordDialog = this.page.locator('dialog-enter-encryption-password');
const decryptErrorDialog = this.page.locator('dialog-handle-decrypt-error');
// Check which dialog appeared (if any)
const passwordDialogVisible = await passwordDialog.isVisible().catch(() => false);
const decryptErrorDialogVisible = await decryptErrorDialog
.isVisible()
.catch(() => false);
const freshDialogVisible = await this.freshClientDialog
.isVisible()
.catch(() => false);
const syncImportDialogVisible = await this.syncImportConflictDialog
.isVisible()
.catch(() => false);
if (config.isEncryptionEnabled !== isCurrentlyEnabled) {
encryptionSettingsChanged = true;
if (config.isEncryptionEnabled) {
if (!config.password) {
throw new Error('Encryption password required to enable encryption');
}
await this.encryptionPasswordInput.waitFor({
state: 'visible',
timeout: 3000,
});
await this.encryptionPasswordInput.fill(config.password);
await this.enableEncryptionBtn.waitFor({ state: 'visible', timeout: 3000 });
await this.enableEncryptionBtn.click();
// Wait for "Enable Encryption?" confirmation dialog
const enableDialog = this.page
.locator('mat-dialog-container')
.filter({ hasText: 'Enable Encryption?' });
const dialogAppeared = await enableDialog
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => true)
.catch(() => false);
if (passwordDialogVisible) {
// Server has encrypted data - enter password and sync
console.log('[SuperSyncPage] Password dialog appeared - entering password');
const passwordInput = passwordDialog.locator('input[type="password"]');
await passwordInput.fill(config.password!);
const saveAndSyncBtn = passwordDialog.locator('button[mat-flat-button]');
await saveAndSyncBtn.click();
await passwordDialog.waitFor({ state: 'hidden', timeout: 30000 });
} else if (decryptErrorDialogVisible) {
// Decryption error - this shouldn't happen with correct password
// but handle it gracefully
console.log('[SuperSyncPage] Decrypt error dialog appeared - unexpected');
throw new Error('Decrypt error dialog appeared - password may be incorrect');
} else if (freshDialogVisible) {
// Fresh client dialog - server has no encrypted data yet
// This means we're Client A (first client)
console.log('[SuperSyncPage] Fresh client dialog - enabling encryption');
await this.freshClientConfirmBtn.click();
await this.freshClientDialog.waitFor({ state: 'hidden', timeout: 5000 });
if (dialogAppeared) {
// Click the confirm button to enable encryption
const confirmBtn = enableDialog
.locator('button[mat-flat-button]')
.filter({ hasText: /enable encryption/i });
await confirmBtn.click();
// Now enable encryption (this is Client A)
await this.ensureOverlaysClosed();
await this.enableEncryption(config.password!);
} else if (syncImportDialogVisible) {
// Sync import conflict - use remote data
console.log('[SuperSyncPage] Sync import conflict dialog - using remote');
await this.syncImportUseRemoteBtn.click();
await this.syncImportConflictDialog.waitFor({ state: 'hidden', timeout: 5000 });
// Wait for the enable encryption dialog to close
await enableDialog.waitFor({ state: 'hidden', timeout: 60000 });
// After handling sync import, check for password dialog
const passwordDialogAfterImport = await passwordDialog
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => true)
.catch(() => false);
// Wait for clean slate operation to complete (server wipe + fresh upload)
await this.page.waitForTimeout(3000);
}
if (passwordDialogAfterImport) {
const passwordInput = passwordDialog.locator('input[type="password"]');
await passwordInput.fill(config.password!);
const saveAndSyncBtn = passwordDialog.locator('button[mat-flat-button]');
await saveAndSyncBtn.click();
await passwordDialog.waitFor({ state: 'hidden', timeout: 30000 });
} else {
await this.disableEncryptionBtn.waitFor({ state: 'visible', timeout: 3000 });
await this.disableEncryptionBtn.click();
// No password dialog - enable encryption (this is Client A)
await this.ensureOverlaysClosed();
await this.enableEncryption(config.password!);
}
} else {
// No dialog appeared - might be first client with empty server
// Wait a bit more for any dialog to appear
await this.page.waitForTimeout(2000);
// Wait for "Disable Encryption?" confirmation dialog
const disableDialog = this.page
.locator('mat-dialog-container')
.filter({ hasText: 'Disable Encryption?' });
const dialogAppeared = await disableDialog
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => true)
.catch(() => false);
// Check again for password dialog (might have appeared after sync started)
const passwordDialogLate = await passwordDialog.isVisible().catch(() => false);
if (dialogAppeared) {
// Click the confirm button to disable encryption
const confirmBtn = disableDialog
.locator('button[mat-flat-button]')
.filter({ hasText: /disable encryption/i });
await confirmBtn.click();
// Wait for the disable encryption dialog to close
await disableDialog.waitFor({ state: 'hidden', timeout: 60000 });
// Wait for clean slate operation to complete (server wipe + fresh upload)
await this.page.waitForTimeout(3000);
}
if (passwordDialogLate) {
console.log(
'[SuperSyncPage] Password dialog appeared late - entering password',
);
const passwordInput = passwordDialog.locator('input[type="password"]');
await passwordInput.fill(config.password!);
const saveAndSyncBtn = passwordDialog.locator('button[mat-flat-button]');
await saveAndSyncBtn.click();
await passwordDialog.waitFor({ state: 'hidden', timeout: 30000 });
} else {
// No password dialog - this is Client A (first client)
// Enable encryption
console.log(
'[SuperSyncPage] No password dialog - enabling encryption as Client A',
);
await this.ensureOverlaysClosed();
await this.enableEncryption(config.password!);
}
}
}
// Save configuration - but the form might already be closed after disabling encryption
const hasConfigDialog = (await this.page.locator('mat-dialog-container').count()) > 0;
if (hasConfigDialog) {
await expect(this.saveBtn).toBeEnabled({ timeout: 5000 });
await this.saveBtn.click();
}
// Wait for sync to complete
const checkAlreadyVisible = await this.syncCheckIcon.isVisible().catch(() => false);
// Wait for the dialog to close
// Skip this check if waitForInitialSync is false, as the sync might trigger
// an error dialog (e.g., decrypt error with wrong password)
if (waitForInitialSync) {
if (!checkAlreadyVisible) {
// Wait for sync to start or complete
const spinnerAppeared = await this.syncSpinner
.waitFor({ state: 'visible', timeout: 2000 })
.then(() => true)
.catch(() => false);
if (spinnerAppeared) {
await this.syncSpinner.waitFor({ state: 'hidden', timeout: 30000 });
}
// Wait for check icon to appear
await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 10000 });
}
} else if (waitForInitialSync) {
// No encryption change needed - just wait for dialogs and sync
// Check if a fresh client confirmation dialog appeared
const freshDialogVisible = await this.freshClientDialog
.isVisible()
.catch(() => false);
if (freshDialogVisible) {
await this.freshClientConfirmBtn.click();
await this.freshClientDialog.waitFor({ state: 'hidden', timeout: 5000 });
}
// Check if a sync import conflict dialog appeared
const syncImportDialogVisible = await this.syncImportConflictDialog
.isVisible()
.catch(() => false);
if (syncImportDialogVisible) {
await this.syncImportUseRemoteBtn.click();
await this.syncImportConflictDialog.waitFor({ state: 'hidden', timeout: 5000 });
}
// Wait for all dialogs to close
await expect(this.page.locator('mat-dialog-container')).toHaveCount(0, {
timeout: 10000,
});
// Wait for initial sync to complete
const checkAlreadyVisible = await this.syncCheckIcon.isVisible().catch(() => false);
if (!checkAlreadyVisible) {
const spinnerAppeared = await this.syncSpinner
.waitFor({ state: 'visible', timeout: 2000 })
.then(() => true)
.catch(() => false);
if (spinnerAppeared) {
await this.syncSpinner.waitFor({ state: 'hidden', timeout: 30000 });
}
await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 5000 });
}
} else if (needsEncryptionEnabled) {
// When waitForInitialSync is false but encryption is needed,
// we need to handle the password dialog that appears when receiving encrypted data
// This is used for testing wrong password scenarios
const passwordDialog = this.page.locator('dialog-enter-encryption-password');
const decryptErrorDialog = this.page.locator('dialog-handle-decrypt-error');
// Wait for password dialog to appear (server has encrypted data)
const passwordDialogAppeared = await passwordDialog
.waitFor({ state: 'visible', timeout: 10000 })
.then(() => true)
.catch(() => false);
if (passwordDialogAppeared) {
// Enter the password (which may be wrong for testing purposes)
console.log(
'[SuperSyncPage] Password dialog appeared - entering password (may be wrong)',
);
const passwordInput = passwordDialog.locator('input[type="password"]');
await passwordInput.fill(config.password!);
const saveAndSyncBtn = passwordDialog.locator('button[mat-flat-button]');
await saveAndSyncBtn.click();
// Wait for either:
// 1. Password dialog to close (password was correct)
// 2. Decrypt error dialog to appear (password was wrong)
// 3. Sync to complete (password was correct)
const result = await Promise.race([
passwordDialog
.waitFor({ state: 'hidden', timeout: 30000 })
.then(() => 'password_dialog_closed'),
decryptErrorDialog
.waitFor({ state: 'visible', timeout: 30000 })
.then(() => 'decrypt_error'),
]).catch(() => 'timeout');
console.log(`[SuperSyncPage] After entering password: ${result}`);
} else {
// No password dialog - wait for sync to start or error
const syncStartedOrFailed = await Promise.race([
this.syncSpinner
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => 'spinner'),
this.syncErrorIcon
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => 'error'),
decryptErrorDialog
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => 'decrypt_error'),
this.page.waitForTimeout(2000).then(() => 'timeout'),
]).catch(() => 'timeout');
console.log(`[SuperSyncPage] Sync started or failed: ${syncStartedOrFailed}`);
}
} else {
// When waitForInitialSync is false, we expect sync to start and potentially show
// an error dialog (e.g., decrypt error with wrong password). We need to ensure:
// 1. The config dialog save action has been processed
// 2. Sync has started (which may trigger error dialogs)
// 3. We don't close error dialogs - tests want to interact with them
//
// Wait for any of these signals that indicate the config dialog has closed:
// - Sync spinner appears (sync started)
// - Sync error icon appears (sync failed immediately)
// - A different dialog appears (like decrypt error dialog)
// - Timeout fallback (sync might complete very quickly)
// When waitForInitialSync is false and no encryption,
// just wait for sync to start or show an error
const syncStartedOrFailed = Promise.race([
this.syncSpinner
.waitFor({ state: 'visible', timeout: 5000 })
@ -312,57 +467,6 @@ export class SuperSyncPage extends BasePage {
this.page.waitForTimeout(2000).then(() => 'timeout'),
]);
await syncStartedOrFailed;
// NOTE: Do NOT call ensureOverlaysClosed() here - if there's a dialog (like
// decrypt error), we want it to stay open for the test to interact with.
}
if (waitForInitialSync) {
// If encryption settings changed, we MUST wait for a fresh sync (clean slate operation)
// Don't skip even if check icon is visible from previous sync
if (encryptionSettingsChanged) {
// Trigger a manual sync to ensure we get latest data after encryption change
await this.page.waitForTimeout(500); // Wait for UI to settle
// Ensure any lingering overlays from encryption dialogs are closed
await this.ensureOverlaysClosed();
await this.syncBtn.click();
// Wait for sync to start
const spinnerAppeared = await this.syncSpinner
.waitFor({ state: 'visible', timeout: 5000 })
.then(() => true)
.catch(() => false);
if (spinnerAppeared) {
// Wait for sync to complete
await this.syncSpinner.waitFor({ state: 'hidden', timeout: 30000 });
}
// Wait for check icon
await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 10000 });
} else {
// Check if sync already completed
const checkAlreadyVisible = await this.syncCheckIcon
.isVisible()
.catch(() => false);
if (!checkAlreadyVisible) {
// Wait for sync to start or complete
// Try to wait for spinner first, but if it's already gone, that's fine
const spinnerAppeared = await this.syncSpinner
.waitFor({ state: 'visible', timeout: 2000 })
.then(() => true)
.catch(() => false);
if (spinnerAppeared) {
// Spinner appeared, wait for it to disappear
// Increased timeout from 15s to 30s for multi-client scenarios under load
await this.syncSpinner.waitFor({ state: 'hidden', timeout: 30000 });
}
// Now wait for check icon to appear
await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 5000 });
}
}
}
}
@ -429,6 +533,8 @@ export class SuperSyncPage extends BasePage {
const confirmBtn = enableDialog
.locator('button[mat-flat-button]')
.filter({ hasText: /enable/i });
// Wait for button to be visible and clickable (dialog animation may delay rendering)
await confirmBtn.waitFor({ state: 'visible', timeout: 10000 });
await confirmBtn.click();
// Wait for the enable encryption dialog to close
@ -447,19 +553,9 @@ export class SuperSyncPage extends BasePage {
timeout: 10000,
});
// CRITICAL: Wait for overlay backdrop to be removed
// Angular Material backdrop removal is asynchronous, and lingering backdrops
// can block subsequent button clicks in tests
const backdrop = this.page.locator('.cdk-overlay-backdrop');
await backdrop
.first()
.waitFor({ state: 'detached', timeout: 3000 })
.catch(() => {
// Non-fatal: backdrop might already be gone
});
// Wait for any snackbars to dismiss
await this.page.waitForTimeout(1000);
// CRITICAL: Ensure all overlays are properly closed
// This handles backdrop removal and any lingering dialogs
await this.ensureOverlaysClosed();
// Wait for clean slate operation to complete (sync will happen automatically)
await this.page.waitForTimeout(3000);
@ -473,7 +569,8 @@ export class SuperSyncPage extends BasePage {
*/
async disableEncryption(): Promise<void> {
// Open sync settings via right-click
await this.syncBtn.click({ button: 'right' });
// Use noWaitAfter to prevent waiting for navigation events
await this.syncBtn.click({ button: 'right', noWaitAfter: true });
await this.providerSelect.waitFor({ state: 'visible', timeout: 10000 });
// CRITICAL: Select "SuperSync" from provider dropdown to load current configuration
@ -488,12 +585,118 @@ export class SuperSyncPage extends BasePage {
const advancedCollapsible = this.page.locator(
'.collapsible-header:has-text("Advanced")',
);
// Debug: Check if Advanced collapsible exists
const advancedExists = await advancedCollapsible.count();
console.log(`[DisableEncryption] Advanced collapsible count: ${advancedExists}`);
// Debug: Get all collapsible headers
const allCollapsibles = await this.page
.locator('.collapsible-header')
.allTextContents();
console.log(
`[DisableEncryption] All collapsible headers: ${JSON.stringify(allCollapsibles)}`,
);
await advancedCollapsible.waitFor({ state: 'visible', timeout: 5000 });
// Check if already expanded
const isExpanded = await this.disableEncryptionBtn.isVisible().catch(() => false);
console.log(`[DisableEncryption] isExpanded before click: ${isExpanded}`);
if (!isExpanded) {
await advancedCollapsible.click();
// Debug: Check if collapsible is clickable
const isClickable = await advancedCollapsible.isEnabled().catch(() => false);
console.log(`[DisableEncryption] Collapsible isEnabled: ${isClickable}`);
// Scroll the collapsible into view
await advancedCollapsible.scrollIntoViewIfNeeded();
await this.page.waitForTimeout(300);
// Try clicking multiple times with different methods
console.log(`[DisableEncryption] Attempting click on collapsible...`);
// Method 1: Regular click with noWaitAfter to prevent navigation blocking
await advancedCollapsible.click({ noWaitAfter: true });
await this.page.waitForTimeout(500);
// Check if expanded
let expandedAfterClick1 =
(await this.page.locator('formly-collapsible.isExpanded').count()) > 0;
console.log(`[DisableEncryption] After click 1: isExpanded=${expandedAfterClick1}`);
if (!expandedAfterClick1) {
// Method 2: Click with force and noWaitAfter
await advancedCollapsible.click({ force: true, noWaitAfter: true });
await this.page.waitForTimeout(500);
expandedAfterClick1 =
(await this.page.locator('formly-collapsible.isExpanded').count()) > 0;
console.log(
`[DisableEncryption] After click 2 (force): isExpanded=${expandedAfterClick1}`,
);
}
if (!expandedAfterClick1) {
// Method 3: Use JavaScript to click
await advancedCollapsible.evaluate((el) => (el as HTMLElement).click());
await this.page.waitForTimeout(500);
expandedAfterClick1 =
(await this.page.locator('formly-collapsible.isExpanded').count()) > 0;
console.log(
`[DisableEncryption] After click 3 (JS): isExpanded=${expandedAfterClick1}`,
);
}
// Debug: Check collapsible state after click
const collapsibleText = await advancedCollapsible
.textContent()
.catch(() => 'unknown');
console.log(`[DisableEncryption] Collapsible text after click: ${collapsibleText}`);
// Debug: Check what buttons are visible in the Advanced section
const enableBtn = await this.enableEncryptionBtn.isVisible().catch(() => false);
const disableBtn = await this.disableEncryptionBtn.isVisible().catch(() => false);
const changeBtn = await this.page
.locator('button:has-text("Change Password")')
.isVisible()
.catch(() => false);
// Debug: Check if the Advanced section content is visible at all
const advancedContent = await this.page
.locator('.collapsible-content')
.first()
.isVisible()
.catch(() => false);
const encryptKeyInput = await this.encryptionPasswordInput
.isVisible()
.catch(() => false);
// Debug: Check the selected provider
const selectedProvider = await this.providerSelect
.textContent()
.catch(() => 'unknown');
// Debug: Check if there are any formly fields inside the collapsible
const formlyFieldsInCollapsible = await this.page
.locator('.collapsible-content formly-field')
.count();
// Debug: Check if there are any hidden formly fields
const hiddenFormlyFields = await this.page
.locator('.collapsible-content formly-field[hidden]')
.count();
// Debug: Check if the collapsible panel exists (expanded state)
const collapsiblePanel = await this.page.locator('.collapsible-panel').count();
// Debug: Check if formly-collapsible has isExpanded class
const isCollapsibleExpanded = await this.page
.locator('formly-collapsible.isExpanded')
.count();
console.log(
`[DisableEncryption] After expand - enableBtn: ${enableBtn}, disableBtn: ${disableBtn}, changeBtn: ${changeBtn}, advancedContent: ${advancedContent}, encryptKeyInput: ${encryptKeyInput}, selectedProvider: ${selectedProvider}, formlyFieldsInCollapsible: ${formlyFieldsInCollapsible}, hiddenFormlyFields: ${hiddenFormlyFields}, collapsiblePanel: ${collapsiblePanel}, isCollapsibleExpanded: ${isCollapsibleExpanded}`,
);
await this.disableEncryptionBtn.waitFor({ state: 'visible', timeout: 3000 });
}
@ -517,6 +720,8 @@ export class SuperSyncPage extends BasePage {
const confirmBtn = confirmDialog
.locator('button[mat-flat-button]')
.filter({ hasText: /disable encryption/i });
// Wait for button to be visible and clickable (dialog animation may delay rendering)
await confirmBtn.waitFor({ state: 'visible', timeout: 10000 });
await confirmBtn.click();
// Wait for the disable encryption dialog to close and the operation to complete
@ -536,19 +741,9 @@ export class SuperSyncPage extends BasePage {
timeout: 10000,
});
// CRITICAL: Wait for overlay backdrop to be removed
// Angular Material backdrop removal is asynchronous, and lingering backdrops
// can block subsequent button clicks in tests (e.g., setupSuperSync called right after)
const backdrop = this.page.locator('.cdk-overlay-backdrop');
await backdrop
.first()
.waitFor({ state: 'detached', timeout: 3000 })
.catch(() => {
// Non-fatal: backdrop might already be gone
});
// Wait for any snackbars to dismiss
await this.page.waitForTimeout(1000);
// CRITICAL: Ensure all overlays are properly closed
// This handles backdrop removal and any lingering dialogs
await this.ensureOverlaysClosed();
// Wait for clean slate operation to complete (sync will happen automatically)
await this.page.waitForTimeout(3000);

View file

@ -113,17 +113,35 @@ test.describe('@supersync SuperSync Encryption', () => {
waitForInitialSync: false, // Sync will fail with wrong password
});
// Try to sync - expectation is that it completes (download happens) but processing fails
// The SyncPage helper might throw if it detects an error icon, or we check for error manually
// The sync already happened during setupSuperSync with wrong password
// A decrypt error dialog should appear - wait for it and close it
const decryptErrorDialog = clientC.page.locator('dialog-handle-decrypt-error');
// We expect the sync to technically "fail" or show an error state because decryption failed
try {
await clientC.sync.triggerSync();
// It might not throw immediately if waitForSyncComplete handles the error state gracefully or we catch it
await clientC.sync.waitForSyncComplete();
} catch (e) {
// Expected error or timeout due to failure
console.log('Sync failed as expected:', e);
// Wait for the decrypt error dialog to appear (it may take a moment after sync fails)
const decryptErrorAppeared = await decryptErrorDialog
.waitFor({ state: 'visible', timeout: 10000 })
.then(() => true)
.catch(() => false);
if (decryptErrorAppeared) {
console.log('Decrypt error dialog appeared - closing it');
// Close the dialog by clicking cancel or pressing Escape
const cancelBtn = decryptErrorDialog
.locator('button')
.filter({ hasText: /cancel/i });
const cancelVisible = await cancelBtn.isVisible().catch(() => false);
if (cancelVisible) {
await cancelBtn.click();
} else {
await clientC.page.keyboard.press('Escape');
}
await decryptErrorDialog.waitFor({ state: 'hidden', timeout: 5000 });
} else {
console.log(
'No decrypt error dialog appeared - checking for other error indicators',
);
// The error might be shown via snackbar instead of dialog
// Just proceed to verify error state
}
// Verify Client C DOES NOT have the task

View file

@ -47,6 +47,22 @@
</mat-form-field>
</form>
@if (providerType === 'supersync') {
<div class="force-overwrite-section">
<p>{{ textKeys.FORCE_OVERWRITE_INFO | translate }}</p>
<button
mat-stroked-button
color="warn"
type="button"
[disabled]="!isValid || isLoading()"
(click)="confirmForceOverwrite()"
>
<mat-icon aria-hidden="true">warning</mat-icon>
{{ textKeys.BTN_FORCE_OVERWRITE | translate }}
</button>
</div>
}
<mat-divider></mat-divider>
<div class="remove-encryption-section">

View file

@ -28,6 +28,21 @@ mat-divider {
margin: 24px 0;
}
.force-overwrite-section {
margin: 8px 0 16px;
p {
margin: 0 0 8px;
font-size: 13px;
color: var(--c-dark-60);
line-height: 1.5;
@include darkTheme {
color: rgba(255, 255, 255, 0.6);
}
}
}
.remove-encryption-section {
h3 {
margin: 0 0 8px;

View file

@ -1,7 +1,8 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialogRef } from '@angular/material/dialog';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import {
DialogChangeEncryptionPasswordComponent,
ChangeEncryptionPasswordResult,
@ -21,6 +22,7 @@ describe('DialogChangeEncryptionPasswordComponent', () => {
let mockFileBasedEncryptionService: jasmine.SpyObj<FileBasedEncryptionService>;
let mockSnackService: jasmine.SpyObj<SnackService>;
let mockEncryptionDisableService: jasmine.SpyObj<EncryptionDisableService>;
let mockMatDialog: jasmine.SpyObj<MatDialog>;
beforeEach(async () => {
mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
@ -36,6 +38,7 @@ describe('DialogChangeEncryptionPasswordComponent', () => {
'disableEncryption',
'disableEncryptionForFileBased',
]);
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
await TestBed.configureTestingModule({
imports: [
@ -55,6 +58,7 @@ describe('DialogChangeEncryptionPasswordComponent', () => {
},
{ provide: SnackService, useValue: mockSnackService },
{ provide: EncryptionDisableService, useValue: mockEncryptionDisableService },
{ provide: MatDialog, useValue: mockMatDialog },
],
}).compileComponents();
@ -150,6 +154,7 @@ describe('DialogChangeEncryptionPasswordComponent', () => {
expect(mockEncryptionPasswordChangeService.changePassword).toHaveBeenCalledWith(
'password123',
undefined,
);
expect(mockSnackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({ type: 'SUCCESS' }),
@ -194,6 +199,38 @@ describe('DialogChangeEncryptionPasswordComponent', () => {
});
});
describe('confirmForceOverwrite', () => {
it('should confirm and force overwrite with allowUnsyncedOps', async () => {
component.newPassword = 'password123';
component.confirmPassword = 'password123';
mockEncryptionPasswordChangeService.changePassword.and.returnValue(
Promise.resolve(),
);
const confirmDialogRef = jasmine.createSpyObj('MatDialogRef', ['afterClosed']);
confirmDialogRef.afterClosed.and.returnValue(of(true));
mockMatDialog.open.and.returnValue(confirmDialogRef);
await component.confirmForceOverwrite();
expect(mockEncryptionPasswordChangeService.changePassword).toHaveBeenCalledWith(
'password123',
{ allowUnsyncedOps: true },
);
});
it('should not proceed when confirmation is cancelled', async () => {
component.newPassword = 'password123';
component.confirmPassword = 'password123';
const confirmDialogRef = jasmine.createSpyObj('MatDialogRef', ['afterClosed']);
confirmDialogRef.afterClosed.and.returnValue(of(false));
mockMatDialog.open.and.returnValue(confirmDialogRef);
await component.confirmForceOverwrite();
expect(mockEncryptionPasswordChangeService.changePassword).not.toHaveBeenCalled();
});
});
describe('cancel', () => {
it('should close dialog with success: false', () => {
component.cancel();

View file

@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogActions,
MatDialogContent,
MatDialogRef,
@ -19,6 +20,7 @@ import { SnackService } from '../../../core/snack/snack.service';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatDivider } from '@angular/material/divider';
import { FileBasedEncryptionService } from '../file-based-encryption.service';
import { DialogConfirmComponent } from '../../../ui/dialog-confirm/dialog-confirm.component';
export interface ChangeEncryptionPasswordResult {
success: boolean;
@ -61,6 +63,7 @@ export class DialogChangeEncryptionPasswordComponent {
private _fileBasedEncryptionService = inject(FileBasedEncryptionService);
private _encryptionDisableService = inject(EncryptionDisableService);
private _snackService = inject(SnackService);
private _matDialog = inject(MatDialog);
private _matDialogRef =
inject<
MatDialogRef<
@ -92,7 +95,7 @@ export class DialogChangeEncryptionPasswordComponent {
return this.newPassword.length >= 8 && this.passwordsMatch;
}
async confirm(): Promise<void> {
async confirm(options?: { allowUnsyncedOps?: boolean }): Promise<void> {
if (!this.isValid || this.isLoading()) {
return;
}
@ -103,7 +106,10 @@ export class DialogChangeEncryptionPasswordComponent {
if (this.providerType === 'file-based') {
await this._fileBasedEncryptionService.changePassword(this.newPassword);
} else {
await this._encryptionPasswordChangeService.changePassword(this.newPassword);
await this._encryptionPasswordChangeService.changePassword(
this.newPassword,
options,
);
}
this._snackService.open({
type: 'SUCCESS',
@ -120,6 +126,27 @@ export class DialogChangeEncryptionPasswordComponent {
}
}
async confirmForceOverwrite(): Promise<void> {
if (this.providerType !== 'supersync' || !this.isValid || this.isLoading()) {
return;
}
const confirmed = await this._matDialog
.open(DialogConfirmComponent, {
data: {
title: this.textKeys.FORCE_OVERWRITE_TITLE,
message: this.textKeys.FORCE_OVERWRITE_CONFIRM,
okTxt: this.textKeys.BTN_FORCE_OVERWRITE,
},
})
.afterClosed()
.toPromise();
if (confirmed) {
await this.confirm({ allowUnsyncedOps: true });
}
}
cancel(): void {
this._matDialogRef.close({ success: false });
}

View file

@ -5,6 +5,12 @@
line-height: 1.5;
margin-bottom: 16px;
}
.force-overwrite-note {
margin-top: 12px;
color: var(--c-dark-60);
font-size: 13px;
}
}
:host mat-form-field {

View file

@ -0,0 +1,76 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { DialogEnterEncryptionPasswordComponent } from './dialog-enter-encryption-password.component';
import { SyncConfigService } from '../sync-config.service';
import { EncryptionPasswordChangeService } from '../encryption-password-change.service';
import { SnackService } from '../../../core/snack/snack.service';
import { SyncProviderManager } from '../../../op-log/sync-providers/provider-manager.service';
import { SyncProviderId } from '../../../op-log/sync-providers/provider.const';
describe('DialogEnterEncryptionPasswordComponent', () => {
let component: DialogEnterEncryptionPasswordComponent;
let fixture: ComponentFixture<DialogEnterEncryptionPasswordComponent>;
let mockDialogRef: jasmine.SpyObj<MatDialogRef<DialogEnterEncryptionPasswordComponent>>;
let mockSyncConfigService: jasmine.SpyObj<SyncConfigService>;
let mockEncryptionPasswordChangeService: jasmine.SpyObj<EncryptionPasswordChangeService>;
let mockSnackService: jasmine.SpyObj<SnackService>;
let mockMatDialog: jasmine.SpyObj<MatDialog>;
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
beforeEach(async () => {
mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
mockSyncConfigService = jasmine.createSpyObj('SyncConfigService', [
'updateEncryptionPassword',
]);
mockEncryptionPasswordChangeService = jasmine.createSpyObj(
'EncryptionPasswordChangeService',
['changePassword'],
);
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
mockProviderManager = jasmine.createSpyObj('SyncProviderManager', [
'getActiveProvider',
]);
mockProviderManager.getActiveProvider.and.returnValue({
id: SyncProviderId.SuperSync,
} as any);
await TestBed.configureTestingModule({
imports: [
DialogEnterEncryptionPasswordComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{ provide: MatDialogRef, useValue: mockDialogRef },
{ provide: SyncConfigService, useValue: mockSyncConfigService },
{
provide: EncryptionPasswordChangeService,
useValue: mockEncryptionPasswordChangeService,
},
{ provide: SnackService, useValue: mockSnackService },
{ provide: MatDialog, useValue: mockMatDialog },
{ provide: SyncProviderManager, useValue: mockProviderManager },
],
}).compileComponents();
fixture = TestBed.createComponent(DialogEnterEncryptionPasswordComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should reset loading and show error when saveAndSync fails', async () => {
component.passwordVal = 'password123';
mockSyncConfigService.updateEncryptionPassword.and.returnValue(
Promise.reject(new Error('fail')),
);
await component.saveAndSync();
expect(component.isLoading()).toBe(false);
expect(mockSnackService.open).toHaveBeenCalled();
expect(mockDialogRef.close).not.toHaveBeenCalled();
});
});

View file

@ -82,6 +82,7 @@ export class DialogEnterEncryptionPasswordComponent {
type: 'ERROR',
msg: T.F.SYNC.S.PERSIST_FAILED,
});
} finally {
this.isLoading.set(false);
}
}

View file

@ -66,6 +66,7 @@ export class DialogSyncInitialCfgComponent implements AfterViewInit {
syncProvider: null,
syncInterval: 300000,
encryptKey: '',
isEncryptionEnabled: false,
localFileSync: {},
webDav: {},
superSync: {},
@ -135,6 +136,8 @@ export class DialogSyncInitialCfgComponent implements AfterViewInit {
providerSpecificUpdate = {
superSync: privateCfg as any,
encryptKey: privateCfg.encryptKey || '',
// SuperSync stores isEncryptionEnabled in privateCfg, not globalCfg
isEncryptionEnabled: (privateCfg as any).isEncryptionEnabled || false,
};
} else if (newProvider === LegacySyncProvider.WebDAV && privateCfg) {
providerSpecificUpdate = {
@ -214,6 +217,8 @@ export class DialogSyncInitialCfgComponent implements AfterViewInit {
}
updateTmpCfg(cfg: SyncConfig): void {
this._tmpUpdatedCfg = cfg;
// Use Object.assign to preserve the object reference for Formly
// This ensures Formly detects changes to the model
Object.assign(this._tmpUpdatedCfg, cfg);
}
}

View file

@ -51,7 +51,10 @@ export class EncryptionPasswordChangeService {
* @param newPassword - The new encryption password
* @throws Error if sync provider is not SuperSync or not ready
*/
async changePassword(newPassword: string): Promise<void> {
async changePassword(
newPassword: string,
options?: { allowUnsyncedOps?: boolean },
): Promise<void> {
SyncLog.normal('EncryptionPasswordChangeService: Starting password change...');
// Get the sync provider
@ -74,12 +77,18 @@ export class EncryptionPasswordChangeService {
const unsyncedUserOps = unsyncedOps.filter(
(entry) => !isFullStateOpType(entry.op.opType),
);
if (unsyncedUserOps.length > 0) {
if (unsyncedUserOps.length > 0 && !options?.allowUnsyncedOps) {
throw new Error(
`Cannot change password: ${unsyncedUserOps.length} operation(s) have not been synced yet. ` +
'Please wait for sync to complete or manually trigger a sync before changing the password.',
);
}
if (unsyncedUserOps.length > 0 && options?.allowUnsyncedOps) {
SyncLog.warn(
`EncryptionPasswordChangeService: Proceeding with password change despite ` +
`${unsyncedUserOps.length} unsynced operation(s) (force overwrite).`,
);
}
// Get current config
const existingCfg =

View file

@ -4,7 +4,11 @@ import { GlobalConfigService } from '../../features/config/global-config.service
import { combineLatest, from, Observable, of } from 'rxjs';
import { SyncConfig } from '../../features/config/global-config.model';
import { switchMap, tap } from 'rxjs/operators';
import { PrivateCfgByProviderId, SyncProviderId } from '../../op-log/sync-exports';
import {
CurrentProviderPrivateCfg,
PrivateCfgByProviderId,
SyncProviderId,
} 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';
@ -91,6 +95,43 @@ export class SyncConfigService {
private _lastSettings: SyncConfig | null = null;
private _deriveEncryptionState(
baseConfig: SyncConfig,
currentProviderCfg: CurrentProviderPrivateCfg | null,
): { isEncryptionEnabled: boolean; encryptKey: string } {
if (!currentProviderCfg) {
return {
isEncryptionEnabled: baseConfig.isEncryptionEnabled ?? false,
encryptKey: '',
};
}
const privateCfg = currentProviderCfg.privateCfg as {
encryptKey?: string;
isEncryptionEnabled?: boolean;
} | null;
if (!privateCfg) {
return {
isEncryptionEnabled: baseConfig.isEncryptionEnabled ?? false,
encryptKey: '',
};
}
const encryptKey = privateCfg.encryptKey ?? '';
if (currentProviderCfg.providerId === SyncProviderId.SuperSync) {
return {
isEncryptionEnabled: privateCfg.isEncryptionEnabled ?? false,
encryptKey,
};
}
return {
isEncryptionEnabled: !!encryptKey,
encryptKey,
};
}
readonly syncSettingsForm$: Observable<SyncConfig> = combineLatest([
this._globalConfigService.sync$,
this._providerManager.currentProviderPrivateCfg$,
@ -147,17 +188,10 @@ export class SyncConfigService {
const prop = PROP_MAP_TO_FORM[currentProviderCfg.providerId];
const providerEncryptKey = (
currentProviderCfg.privateCfg as { encryptKey?: string } | null
)?.encryptKey;
// For SuperSync, extract isEncryptionEnabled from privateCfg
// For file-based providers, treat encryptKey as the source of truth
const isEncryptionEnabled =
currentProviderCfg.providerId === SyncProviderId.SuperSync
? ((currentProviderCfg.privateCfg as { isEncryptionEnabled?: boolean } | null)
?.isEncryptionEnabled ?? false)
: !!providerEncryptKey || (baseConfig.isEncryptionEnabled ?? false);
const { isEncryptionEnabled, encryptKey } = this._deriveEncryptionState(
baseConfig,
currentProviderCfg,
);
// DEBUG: Log the encryption state
SyncLog.log(
@ -166,7 +200,7 @@ export class SyncConfigService {
'providerId:',
currentProviderCfg.providerId,
'privateCfg.encryptKey:',
providerEncryptKey,
encryptKey,
'privateCfg.isEncryptionEnabled:',
(currentProviderCfg.privateCfg as { isEncryptionEnabled?: boolean } | null)
?.isEncryptionEnabled,
@ -175,7 +209,7 @@ export class SyncConfigService {
// Create config with provider-specific settings
const result: SyncConfig = {
...baseConfig,
encryptKey: currentProviderCfg?.privateCfg?.encryptKey || '',
encryptKey,
isEncryptionEnabled,
// Reset provider-specific configs to defaults first
localFileSync: DEFAULT_GLOBAL_CONFIG.sync.localFileSync,
@ -225,6 +259,9 @@ export class SyncConfigService {
await this._providerManager.setProviderConfig(activeProvider.id, newConfig);
// Ensure global config reflects encryption enabled when password is entered
this._globalConfigService.updateSection('sync', { isEncryptionEnabled: true });
// Clear cached encryption keys to force re-derivation with new password
this._derivedKeyCache.clearCache();
// Clear cached adapters to force recreation with new encryption settings

View file

@ -672,6 +672,9 @@ export class SyncWrapperService {
if (result?.password) {
// Password was entered and saved, re-sync
this.sync();
} else if (result?.forceOverwrite) {
// Force overwrite succeeded; reflect synced status
this._providerManager.setSyncStatus('IN_SYNC');
} else {
// User cancelled - set status to unknown
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');

View file

@ -969,6 +969,10 @@ const T = {
MESSAGE: 'F.SYNC.D_ENTER_PASSWORD.MESSAGE',
PASSWORD_LABEL: 'F.SYNC.D_ENTER_PASSWORD.PASSWORD_LABEL',
BTN_SAVE_AND_SYNC: 'F.SYNC.D_ENTER_PASSWORD.BTN_SAVE_AND_SYNC',
FORCE_OVERWRITE_INFO: 'F.SYNC.D_ENTER_PASSWORD.FORCE_OVERWRITE_INFO',
FORCE_OVERWRITE_TITLE: 'F.SYNC.D_ENTER_PASSWORD.FORCE_OVERWRITE_TITLE',
FORCE_OVERWRITE_CONFIRM: 'F.SYNC.D_ENTER_PASSWORD.FORCE_OVERWRITE_CONFIRM',
BTN_FORCE_OVERWRITE: 'F.SYNC.D_ENTER_PASSWORD.BTN_FORCE_OVERWRITE',
},
D_FRESH_CLIENT_CONFIRM: {
MESSAGE: 'F.SYNC.D_FRESH_CLIENT_CONFIRM.MESSAGE',
@ -1052,12 +1056,16 @@ const T = {
L_CHANGE_ENCRYPTION_PASSWORD:
'F.SYNC.FORM.SUPER_SYNC.L_CHANGE_ENCRYPTION_PASSWORD',
CHANGE_PASSWORD_WARNING: 'F.SYNC.FORM.SUPER_SYNC.CHANGE_PASSWORD_WARNING',
FORCE_OVERWRITE_INFO: 'F.SYNC.FORM.SUPER_SYNC.FORCE_OVERWRITE_INFO',
FORCE_OVERWRITE_TITLE: 'F.SYNC.FORM.SUPER_SYNC.FORCE_OVERWRITE_TITLE',
FORCE_OVERWRITE_CONFIRM: 'F.SYNC.FORM.SUPER_SYNC.FORCE_OVERWRITE_CONFIRM',
CHANGE_PASSWORD_SUCCESS: 'F.SYNC.FORM.SUPER_SYNC.CHANGE_PASSWORD_SUCCESS',
L_NEW_PASSWORD: 'F.SYNC.FORM.SUPER_SYNC.L_NEW_PASSWORD',
L_CONFIRM_PASSWORD: 'F.SYNC.FORM.SUPER_SYNC.L_CONFIRM_PASSWORD',
PASSWORDS_DONT_MATCH: 'F.SYNC.FORM.SUPER_SYNC.PASSWORDS_DONT_MATCH',
PASSWORD_MIN_LENGTH: 'F.SYNC.FORM.SUPER_SYNC.PASSWORD_MIN_LENGTH',
BTN_CHANGE_PASSWORD: 'F.SYNC.FORM.SUPER_SYNC.BTN_CHANGE_PASSWORD',
BTN_FORCE_OVERWRITE: 'F.SYNC.FORM.SUPER_SYNC.BTN_FORCE_OVERWRITE',
PASSWORD_SET_INFO: 'F.SYNC.FORM.SUPER_SYNC.PASSWORD_SET_INFO',
DISABLE_ENCRYPTION_TITLE: 'F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_TITLE',
DISABLE_ENCRYPTION_WARNING: 'F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_WARNING',

View file

@ -943,7 +943,11 @@
"TITLE": "Encryption Password Required",
"MESSAGE": "The sync server contains encrypted data, but no encryption password is configured on this device. Please enter your encryption password to decrypt and sync your data.",
"PASSWORD_LABEL": "Encryption Password",
"BTN_SAVE_AND_SYNC": "Save & Sync"
"BTN_SAVE_AND_SYNC": "Save & Sync",
"FORCE_OVERWRITE_INFO": "If you dont know the old password, you can overwrite the server with your local data and a new password.",
"FORCE_OVERWRITE_TITLE": "Overwrite Server Data?",
"FORCE_OVERWRITE_CONFIRM": "This will <strong>delete all data on the server</strong> and upload your local data with the new password. Unsynced local changes may be lost. Continue?",
"BTN_FORCE_OVERWRITE": "Force Overwrite Server"
},
"D_FRESH_CLIENT_CONFIRM": {
"MESSAGE": "This appears to be a fresh installation. Remote data with {{count}} changes was found. Do you want to download and apply this data?",
@ -1025,12 +1029,16 @@
"ENCRYPTION_WARNING": "WARNING: If you forget your encryption password, your data cannot be recovered. This password is separate from your login password. You must use the same password on all devices.",
"L_CHANGE_ENCRYPTION_PASSWORD": "Change Encryption Password",
"CHANGE_PASSWORD_WARNING": "WARNING: This will permanently delete ALL sync history and backups on the server. Previous versions cannot be recovered. Your current data will be re-uploaded with the new password. All other devices will need the new password.",
"FORCE_OVERWRITE_INFO": "If you can't sync because you don't know the current encryption password, you can overwrite the server with your local data and a new password.",
"FORCE_OVERWRITE_TITLE": "Overwrite Server Data?",
"FORCE_OVERWRITE_CONFIRM": "This will <strong>delete all data on the server</strong> and upload your local data with the new password. Unsynced local changes may be lost. Continue?",
"CHANGE_PASSWORD_SUCCESS": "Encryption password changed successfully",
"L_NEW_PASSWORD": "New Encryption Password",
"L_CONFIRM_PASSWORD": "Confirm Password",
"PASSWORDS_DONT_MATCH": "Passwords do not match",
"PASSWORD_MIN_LENGTH": "Password must be at least 8 characters",
"BTN_CHANGE_PASSWORD": "Change Password",
"BTN_FORCE_OVERWRITE": "Force Overwrite Server",
"PASSWORD_SET_INFO": "✓ Encryption password is set. Use the 'Change Password' button to modify it.",
"L_REMOVE_ENCRYPTION": "Or Remove Encryption",
"REMOVE_ENCRYPTION_WARNING": "Removing encryption will delete all server data and re-upload your data without encryption. All devices will need to reconfigure.",