diff --git a/android/.idea/gradle.xml b/android/.idea/gradle.xml
index 4b14863461..5a12450e3d 100644
--- a/android/.idea/gradle.xml
+++ b/android/.idea/gradle.xml
@@ -14,8 +14,10 @@
+
+
diff --git a/e2e/tests/sync/supersync-wrong-password-error.spec.ts b/e2e/tests/sync/supersync-wrong-password-error.spec.ts
index 80f17cb73b..ec6c664a31 100644
--- a/e2e/tests/sync/supersync-wrong-password-error.spec.ts
+++ b/e2e/tests/sync/supersync-wrong-password-error.spec.ts
@@ -105,11 +105,22 @@ test.describe('@supersync @encryption Wrong Password Error Handling', () => {
'[WrongPassword] Phase 4: Client B attempting sync with wrong password',
);
- // Trigger sync manually (it will fail with DecryptError)
- await clientB.sync.triggerSync();
+ // The sync starts automatically after setup. Check if the decrypt error dialog
+ // is already open before trying to trigger sync manually.
+ const decryptErrorDialogEarly = clientB.page.locator('dialog-handle-decrypt-error');
+ const dialogAlreadyOpen = await decryptErrorDialogEarly
+ .waitFor({ state: 'visible', timeout: 3000 })
+ .then(() => true)
+ .catch(() => false);
- // Wait a bit for the error to be processed
- await clientB.page.waitForTimeout(2000);
+ if (!dialogAlreadyOpen) {
+ // Dialog not open yet - trigger sync manually
+ await clientB.sync.triggerSync();
+ // Wait a bit for the error to be processed
+ await clientB.page.waitForTimeout(2000);
+ } else {
+ console.log('[WrongPassword] Decrypt error dialog already open from auto-sync');
+ }
// ============ PHASE 5: Verify error handling ============
console.log('[WrongPassword] Phase 5: Verifying error is properly surfaced');
@@ -155,13 +166,13 @@ test.describe('@supersync @encryption Wrong Password Error Handling', () => {
await passwordInput.fill(newPassword);
console.log('[WrongPassword] Filled in correct password');
- // Click the "Re-sync" or similar button to retry
+ // Click the "Change & Attempt Decrypt" button to retry with new password
const resyncBtn = decryptErrorDialog
.locator('button')
- .filter({ hasText: /sync|retry/i })
+ .filter({ hasText: /change.*decrypt|attempt.*decrypt/i })
.first();
await resyncBtn.click();
- console.log('[WrongPassword] Clicked re-sync button');
+ console.log('[WrongPassword] Clicked Change & Attempt Decrypt button');
// Wait for dialog to close
await decryptErrorDialog.waitFor({ state: 'hidden', timeout: 10000 });
@@ -243,22 +254,49 @@ test.describe('@supersync @encryption Wrong Password Error Handling', () => {
waitForInitialSync: false,
});
+ // The sync starts automatically after setup and may trigger the decrypt error dialog.
+ // If dialog appears, cancel it so we can add a local task first.
+ const decryptErrorDialogEarly = clientB.page.locator('dialog-handle-decrypt-error');
+ const dialogOpenedEarly = await decryptErrorDialogEarly
+ .waitFor({ state: 'visible', timeout: 3000 })
+ .then(() => true)
+ .catch(() => false);
+
+ if (dialogOpenedEarly) {
+ // Cancel the dialog so we can add a local task
+ const cancelBtn = decryptErrorDialogEarly
+ .locator('button')
+ .filter({ hasText: /cancel/i });
+ await cancelBtn.click();
+ await decryptErrorDialogEarly.waitFor({ state: 'hidden', timeout: 5000 });
+ await clientB.page.waitForTimeout(500);
+ }
+
// Client B has its own local task
const taskB = `TaskFromB-${uniqueId}`;
await clientB.workView.addTask(taskB);
// Trigger sync - should fail with DecryptError
- await clientB.sync.triggerSync();
- await clientB.page.waitForTimeout(2000);
+ // NOTE: Don't use triggerSync() because it waits for success check icon
+ // With wrong password, sync will fail and show error dialog instead
+ const syncBtn = clientB.page.locator('button.sync-btn');
+ await syncBtn.click();
- // Verify error dialog opens
+ // Wait for the decrypt error dialog to appear
const decryptErrorDialog = clientB.page.locator('dialog-handle-decrypt-error');
- await decryptErrorDialog.waitFor({ state: 'visible', timeout: 5000 });
+ await decryptErrorDialog.waitFor({ state: 'visible', timeout: 10000 });
- // Look for "Overwrite" or "Force Upload" button
+ // The "Change & Overwrite Remote" button requires a password to be entered first
+ // Enter any password (it will become the new encryption password after overwrite)
+ const passwordInput = decryptErrorDialog.locator('input[type="password"]');
+ const newPassword = `overwrite-pass-${uniqueId}`;
+ await passwordInput.fill(newPassword);
+ await clientB.page.waitForTimeout(300); // Wait for form validation
+
+ // Look for "Change & Overwrite Remote" button (should be enabled now)
const overwriteBtn = decryptErrorDialog
.locator('button')
- .filter({ hasText: /overwrite|force|upload/i })
+ .filter({ hasText: /overwrite.*remote|change.*overwrite/i })
.first();
const hasOverwriteBtn = await overwriteBtn.isVisible().catch(() => false);
diff --git a/src/app/op-log/capture/operation-log.effects.spec.ts b/src/app/op-log/capture/operation-log.effects.spec.ts
index 5e597bb783..6370f7bef7 100644
--- a/src/app/op-log/capture/operation-log.effects.spec.ts
+++ b/src/app/op-log/capture/operation-log.effects.spec.ts
@@ -54,6 +54,7 @@ describe('OperationLogEffects', () => {
'appendWithVectorClockUpdate',
'getCompactionCounter',
'clearVectorClockCache',
+ 'getProtectedClientIds',
]);
mockLockService = jasmine.createSpyObj('LockService', ['request']);
mockVectorClockService = jasmine.createSpyObj('VectorClockService', [
@@ -82,6 +83,7 @@ describe('OperationLogEffects', () => {
mockOpLogStore.append.and.returnValue(Promise.resolve(1));
mockOpLogStore.appendWithVectorClockUpdate.and.returnValue(Promise.resolve(1));
mockOpLogStore.getCompactionCounter.and.returnValue(Promise.resolve(0));
+ mockOpLogStore.getProtectedClientIds.and.returnValue(Promise.resolve([]));
mockVectorClockService.getCurrentVectorClock.and.returnValue(
Promise.resolve({ testClient: 5 }),
);
diff --git a/src/app/op-log/capture/operation-log.effects.ts b/src/app/op-log/capture/operation-log.effects.ts
index 2439dc9cfe..4d2f36f609 100644
--- a/src/app/op-log/capture/operation-log.effects.ts
+++ b/src/app/op-log/capture/operation-log.effects.ts
@@ -174,8 +174,14 @@ export class OperationLogEffects {
};
const currentClock = await this.vectorClockService.getCurrentVectorClock();
const incrementedClock = incrementVectorClock(currentClock, clientId);
+ // Load protected client IDs (e.g., from latest SYNC_IMPORT) to preserve during pruning
+ const protectedClientIds = await this.opLogStore.getProtectedClientIds();
// Limit vector clock size to prevent unbounded growth with many clients
- const newClock = limitVectorClockSize(incrementedClock, clientId);
+ const newClock = limitVectorClockSize(
+ incrementedClock,
+ clientId,
+ protectedClientIds,
+ );
// For bulk operations, entityIds is provided but entityId may not be.
// The server requires entityId for non-full-state operations.