diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 71091f933f..36a73b47db 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -3,6 +3,33 @@ import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +/** + * Warm up the dev server by fetching the app once before any tests start. + * This ensures Angular compilation is complete and cached, so all workers + * get instant responses instead of competing for the first compilation. + */ +const warmUpDevServer = async (baseURL: string): Promise => { + console.log(`Warming up dev server at ${baseURL}...`); + const maxRetries = 3; + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await fetch(baseURL, { signal: AbortSignal.timeout(30000) }); + if (response.ok) { + // Read the full body to ensure the server finishes compilation + await response.text(); + console.log('Dev server warm-up complete'); + return; + } + } catch { + if (attempt < maxRetries - 1) { + console.log(`Warm-up attempt ${attempt + 1} failed, retrying...`); + await new Promise((r) => setTimeout(r, 2000)); + } + } + } + console.warn('Dev server warm-up failed — tests may be slow on first run'); +}; + const globalSetup = async (config: FullConfig): Promise => { // Set test environment variables process.env.TZ = 'Europe/Berlin'; @@ -30,6 +57,13 @@ const globalSetup = async (config: FullConfig): Promise => { throw error; // Fail fast if plugin build fails } } + + // Warm up the dev server to pre-compile Angular bundles before workers start + const baseURL = + process.env.E2E_BASE_URL || + config.projects[0]?.use?.baseURL || + 'http://localhost:4242'; + await warmUpDevServer(baseURL); }; export default globalSetup; diff --git a/e2e/pages/supersync.page.ts b/e2e/pages/supersync.page.ts index 32137fadeb..7c6b51a133 100644 --- a/e2e/pages/supersync.page.ts +++ b/e2e/pages/supersync.page.ts @@ -246,9 +246,10 @@ export class SuperSyncPage extends BasePage { // 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'); + const enableEncryptionDialog = this.page.locator('dialog-enable-encryption'); - // Wait for config dialog to close OR password dialog to appear - // (password dialog appearing means config dialog closed and sync started) + // Wait for config dialog to close, password dialog to appear, or + // mandatory encryption setup dialog to appear (SuperSync requires encryption) const configDialogClosed = await Promise.race([ configDialog .waitFor({ state: 'hidden', timeout: 15000 }) @@ -256,9 +257,36 @@ export class SuperSyncPage extends BasePage { passwordDialog .waitFor({ state: 'visible', timeout: 15000 }) .then(() => 'password_appeared'), + enableEncryptionDialog + .first() + .waitFor({ state: 'visible', timeout: 15000 }) + .then(() => 'enable_encryption_appeared'), ]).catch(() => 'timeout'); - if (configDialogClosed === 'timeout') { + if (configDialogClosed === 'enable_encryption_appeared') { + // Mandatory encryption setup dialog appeared — fill password and confirm. + // The config dialog AND the sync-wrapper may both open instances, so handle + // all of them in a loop, always targeting the topmost (highest index) one. + const defaultPw = config.password || 'e2e-default-encryption-pw'; + console.log('[SuperSyncPage] Mandatory encryption dialog appeared after save'); + for (let round = 0; round < 3; round++) { + const encCount = await enableEncryptionDialog.count(); + if (encCount === 0) break; + console.log( + `[SuperSyncPage] Handling encryption dialog (${encCount} open, round ${round})`, + ); + const topDialog = enableEncryptionDialog.nth(encCount - 1); + await topDialog.locator('input[type="password"]').first().fill(defaultPw); + await topDialog.locator('input[type="password"]').nth(1).fill(defaultPw); + await topDialog.locator('button[mat-flat-button]').click(); + await expect(enableEncryptionDialog).toHaveCount(encCount - 1, { + timeout: 15000, + }); + } + // After all encryption dialogs close, the config dialog save handler + // continues and closes the config dialog + await configDialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); + } else 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', @@ -293,25 +321,26 @@ export class SuperSyncPage extends BasePage { // BEFORE the server's encrypted data triggers the password dialog. // Instead, we wait specifically for dialogs that indicate definitive outcomes. // If no dialog appears within timeout, THEN we check if sync succeeded. + const encSyncTimeout = 30000; const outcome = await Promise.race([ passwordDialog - .waitFor({ state: 'visible', timeout: 15000 }) + .waitFor({ state: 'visible', timeout: encSyncTimeout }) .then(() => 'password_dialog' as const), this.freshClientDialog - .waitFor({ state: 'visible', timeout: 15000 }) + .waitFor({ state: 'visible', timeout: encSyncTimeout }) .then(() => 'fresh_client_dialog' as const), this.syncImportConflictDialog - .waitFor({ state: 'visible', timeout: 15000 }) + .waitFor({ state: 'visible', timeout: encSyncTimeout }) .then(() => 'sync_import_dialog' as const), decryptErrorDialog - .waitFor({ state: 'visible', timeout: 15000 }) + .waitFor({ state: 'visible', timeout: encSyncTimeout }) .then(() => 'decrypt_error_dialog' as const), // Sync error icon indicates encrypted data received but no password this.syncErrorIcon - .waitFor({ state: 'visible', timeout: 15000 }) + .waitFor({ state: 'visible', timeout: encSyncTimeout }) .then(() => 'sync_error' as const), // Timeout fallback - we'll check state manually after timeout - this.page.waitForTimeout(15000).then(() => 'timeout' as const), + this.page.waitForTimeout(encSyncTimeout).then(() => 'timeout' as const), ]).catch(() => 'error' as const); console.log(`[SuperSyncPage] Sync outcome: ${outcome}`); @@ -541,36 +570,160 @@ export class SuperSyncPage extends BasePage { 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 }); + // Encryption is mandatory for SuperSync, so even when the caller doesn't + // explicitly set isEncryptionEnabled, we must handle encryption dialogs. + const defaultPassword = config.password || 'e2e-default-encryption-pw'; + const enterPasswordDialog = this.page.locator('dialog-enter-encryption-password'); + + // IMPORTANT: Wait for the initial sync to complete or for a dialog to appear + // BEFORE entering the dialog loop. The sync runs asynchronously after save(), + // and the mandatory encryption dialog (_promptSuperSyncEncryptionIfNeeded) + // opens AFTER sync completes. Without this wait, the dialog loop finds no + // dialogs and breaks immediately, missing the encryption dialog entirely. + console.log('[SuperSyncPage] Waiting for initial sync outcome...'); + const syncTimeout = 45000; + const initialOutcome = await Promise.race([ + enableEncryptionDialog + .first() + .waitFor({ state: 'visible', timeout: syncTimeout }) + .then(() => 'enable_encryption' as const), + enterPasswordDialog + .waitFor({ state: 'visible', timeout: syncTimeout }) + .then(() => 'enter_password' as const), + this.freshClientDialog + .waitFor({ state: 'visible', timeout: syncTimeout }) + .then(() => 'fresh_client' as const), + this.syncImportConflictDialog + .waitFor({ state: 'visible', timeout: syncTimeout }) + .then(() => 'sync_import' as const), + passwordDialog + .waitFor({ state: 'visible', timeout: syncTimeout }) + .then(() => 'password' as const), + // Sync error icon means encrypted data without password — wait for dialog + this.syncErrorIcon + .waitFor({ state: 'visible', timeout: syncTimeout }) + .then(() => 'sync_error' as const), + // Sync check icon means sync completed. The encryption dialog opens AFTER + // sync via the async _promptSuperSyncEncryptionIfNeeded(), so we add a + // 2s delay. If the encryption dialog appears during this delay, its promise + // resolves first and wins the race (because our .then() hasn't resolved yet). + this.syncCheckIcon + .waitFor({ state: 'visible', timeout: syncTimeout }) + .then(() => this.page.waitForTimeout(2000)) + .then(() => 'sync_success' as const), + ]).catch(() => 'timeout' as const); + console.log(`[SuperSyncPage] Initial sync outcome: ${initialOutcome}`); + + // If sync completed/errored without a dialog, wait briefly for post-sync dialogs + if ( + initialOutcome === 'sync_error' || + initialOutcome === 'sync_success' || + initialOutcome === 'timeout' + ) { + console.log('[SuperSyncPage] Checking for late-appearing post-sync dialogs...'); + await Promise.race([ + enableEncryptionDialog.first().waitFor({ state: 'visible', timeout: 3000 }), + enterPasswordDialog.waitFor({ state: 'visible', timeout: 3000 }), + passwordDialog.waitFor({ state: 'visible', timeout: 3000 }), + ]).catch(() => {}); } - // 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 }); + // Handle dialogs in a loop — multiple can appear in sequence + for (let dialogRound = 0; dialogRound < 5; dialogRound++) { + // Check what dialog is currently visible + const freshDialogVisible = await this.freshClientDialog + .isVisible() + .catch(() => false); + if (freshDialogVisible) { + await this.freshClientConfirmBtn.click(); + await this.freshClientDialog.waitFor({ state: 'hidden', timeout: 5000 }); + continue; + } + + const syncImportDialogVisible = await this.syncImportConflictDialog + .isVisible() + .catch(() => false); + if (syncImportDialogVisible) { + await this.syncImportUseRemoteBtn.click(); + await this.syncImportConflictDialog.waitFor({ + state: 'hidden', + timeout: 5000, + }); + continue; + } + + // Client A: mandatory encryption setup dialog + const enableEncVisible = await enableEncryptionDialog + .isVisible() + .catch(() => false); + if (enableEncVisible) { + console.log('[SuperSyncPage] Mandatory encryption dialog — setting password'); + const topEncDlg = enableEncryptionDialog.last(); + const pwInput = topEncDlg.locator('input[type="password"]').first(); + const confirmPwInput = topEncDlg.locator('input[type="password"]').nth(1); + await pwInput.fill(defaultPassword); + await confirmPwInput.fill(defaultPassword); + const setPasswordBtn = topEncDlg.locator('button[mat-flat-button]'); + await setPasswordBtn.click(); + await topEncDlg.waitFor({ + state: 'hidden', + timeout: 15000, + }); + continue; + } + + // Client B: enter-password dialog (server has encrypted data) + const enterPwVisible = await enterPasswordDialog.isVisible().catch(() => false); + if (enterPwVisible) { + console.log('[SuperSyncPage] Enter-password dialog — entering password'); + const passwordInput = enterPasswordDialog.locator('input[type="password"]'); + await passwordInput.fill(defaultPassword); + const saveAndSyncBtn = enterPasswordDialog.locator('button[mat-flat-button]'); + await saveAndSyncBtn.click(); + await enterPasswordDialog.waitFor({ + state: 'hidden', + timeout: 30000, + }); + continue; + } + + // Client B: legacy password dialog (dialog-enter-encryption-password vs + // dialog-sync-initial-cfg password prompt) + const legacyPwVisible = await passwordDialog.isVisible().catch(() => false); + if (legacyPwVisible) { + console.log('[SuperSyncPage] Password dialog — entering password'); + const passwordInput = passwordDialog.locator('input[type="password"]'); + await passwordInput.fill(defaultPassword); + const saveAndSyncBtn = passwordDialog.locator('button[mat-flat-button]'); + await saveAndSyncBtn.click(); + await passwordDialog.waitFor({ + state: 'hidden', + timeout: 30000, + }); + continue; + } + + // No dialog currently visible — wait for one to appear or for dialogs to clear + const dialogCount = await this.page.locator('mat-dialog-container').count(); + if (dialogCount === 0) { + break; // All dialogs closed + } + + // Wait briefly for dialog state to settle, then re-check + await this.page.waitForTimeout(1000); } // Wait for all dialogs to close await expect(this.page.locator('mat-dialog-container')).toHaveCount(0, { - timeout: 10000, + timeout: 15000, }); - // Wait for initial sync to complete + // Wait for sync to complete (either already done or triggered after dialog) const checkAlreadyVisible = await this.syncCheckIcon.isVisible().catch(() => false); if (!checkAlreadyVisible) { const spinnerAppeared = await this.syncSpinner - .waitFor({ state: 'visible', timeout: 2000 }) + .waitFor({ state: 'visible', timeout: 5000 }) .then(() => true) .catch(() => false); @@ -578,7 +731,7 @@ export class SuperSyncPage extends BasePage { await this.syncSpinner.waitFor({ state: 'hidden', timeout: 30000 }); } - await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 5000 }); + await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 15000 }); } } else if (needsEncryptionEnabled) { // When waitForInitialSync is false but encryption is needed, diff --git a/e2e/tests/sync/import-sync.spec.ts b/e2e/tests/sync/import-sync.spec.ts index e0d0bd30ca..95c8fba18c 100644 --- a/e2e/tests/sync/import-sync.spec.ts +++ b/e2e/tests/sync/import-sync.spec.ts @@ -28,6 +28,9 @@ const generateTestRunId = (workerIndex: number): string => { return `${Date.now()}-${workerIndex}`; }; +/** Default encryption password used by setupSuperSync's mandatory encryption dialog */ +const ENCRYPTION_PASSWORD = 'e2e-default-encryption-pw'; + base.describe('@importsync @supersync Import + Sync E2E', () => { let serverHealthy: boolean | null = null; @@ -90,10 +93,10 @@ base.describe('@importsync @supersync Import + Sync E2E', () => { await importPage.importBackupFile(backupPath); console.log('[Import Test] Client A imported backup successfully'); - // Reload page after import to ensure UI reflects the imported state - // (bulk state updates from BACKUP_IMPORT may not trigger component re-renders) - // Use goto instead of reload - more reliable with service workers - await clientA.page.goto(clientA.page.url(), { + // Navigate to tasks view after import to ensure UI reflects the imported state. + // Use explicit URL instead of page.url() because after import the page may be on + // the settings/import route, and the imported state may redirect to /#/config. + await clientA.page.goto('/#/tag/TODAY/tasks', { waitUntil: 'domcontentloaded', timeout: 30000, }); @@ -117,7 +120,13 @@ base.describe('@importsync @supersync Import + Sync E2E', () => { // ============ PHASE 3: Client B Downloads via Sync ============ console.log('[Import Test] Phase 3: Client B syncing from server'); clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); + // Client B must use explicit encryption config to enter the password dialog + // instead of the enable-encryption dialog (which would wipe server data). + await clientB.sync.setupSuperSync({ + ...syncConfig, + isEncryptionEnabled: true, + password: ENCRYPTION_PASSWORD, + }); await clientB.sync.syncAndWait(); console.log('[Import Test] Client B sync complete'); @@ -267,9 +276,15 @@ base.describe('@importsync @supersync Import + Sync E2E', () => { await clientA.sync.syncAndWait(); console.log(`[Merge Test] Client A created and synced: ${existingTaskA}`); - // Client B gets the task + // Client B gets the task. + // Must use explicit encryption config so Client B enters the password dialog + // instead of the enable-encryption dialog (which would wipe Client A's data). clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.setupSuperSync({ + ...syncConfig, + isEncryptionEnabled: true, + password: ENCRYPTION_PASSWORD, + }); await clientB.sync.syncAndWait(); await waitForTask(clientB.page, existingTaskA); @@ -295,10 +310,10 @@ base.describe('@importsync @supersync Import + Sync E2E', () => { await importPage.importBackupFile(backupPath); console.log('[Merge Test] Client A imported backup'); - // Reload page after import to ensure UI reflects the imported state - // (bulk state updates from BACKUP_IMPORT may not trigger component re-renders) - // Use goto instead of reload - more reliable with service workers - await clientA.page.goto(clientA.page.url(), { + // Navigate to tasks view after import. + // Use explicit URL instead of page.url() because after import the page may be on + // the settings/import route, and the imported state may redirect to /#/config. + await clientA.page.goto('/#/tag/TODAY/tasks', { waitUntil: 'domcontentloaded', timeout: 30000, }); diff --git a/e2e/tests/sync/supersync-account-reset.spec.ts b/e2e/tests/sync/supersync-account-reset.spec.ts index ae58f35e17..1434936e8f 100644 --- a/e2e/tests/sync/supersync-account-reset.spec.ts +++ b/e2e/tests/sync/supersync-account-reset.spec.ts @@ -11,6 +11,9 @@ import { type TestUser, } from '../../utils/supersync-helpers'; +/** Default encryption password used by setupSuperSync's mandatory encryption dialog */ +const ENCRYPTION_PASSWORD = 'e2e-default-encryption-pw'; + /** * Reset user's sync data on the server (keeps account active). * This simulates what happens when clicking "Reset Account (Clear Data)" button. @@ -87,9 +90,15 @@ test.describe('@supersync SuperSync Account Reset', () => { await waitForTask(clientA.page, task2); console.log('[Reset] Client A synced initial tasks'); - // 2. Create second client and verify it receives the tasks + // 2. Create second client and verify it receives the tasks. + // Must use explicit encryption config so Client B enters the password dialog + // instead of the enable-encryption dialog (which would wipe Client A's data). clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); - await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.setupSuperSync({ + ...syncConfig, + isEncryptionEnabled: true, + password: ENCRYPTION_PASSWORD, + }); await clientB.sync.syncAndWait(); await waitForTask(clientB.page, task1); await waitForTask(clientB.page, task2); @@ -171,14 +180,20 @@ test.describe('@supersync SuperSync Account Reset', () => { expect(taskExists).toBe(true); console.log('[Resync] Client retained local data after reset'); - // 5. Create another client to verify data was re-synced to server + // 5. Create another client to verify data was re-synced to server. + // Must use explicit encryption config so the verify client enters the password dialog + // instead of the enable-encryption dialog (which would wipe server data). const clientVerify = await createSimulatedClient( browser, appUrl, 'Verify', testRunId, ); - await clientVerify.sync.setupSuperSync(syncConfig); + await clientVerify.sync.setupSuperSync({ + ...syncConfig, + isEncryptionEnabled: true, + password: ENCRYPTION_PASSWORD, + }); await clientVerify.sync.syncAndWait(); // New client should receive the re-synced task diff --git a/e2e/tests/sync/supersync-encryption-prompt-loop.spec.ts b/e2e/tests/sync/supersync-encryption-prompt-loop.spec.ts new file mode 100644 index 0000000000..bd6115443e --- /dev/null +++ b/e2e/tests/sync/supersync-encryption-prompt-loop.spec.ts @@ -0,0 +1,162 @@ +import { test, expect } from '../../fixtures/supersync.fixture'; +import { + createTestUser, + getSuperSyncConfig, + createSimulatedClient, + closeClient, + type SimulatedE2EClient, +} from '../../utils/supersync-helpers'; + +/** + * SuperSync Encryption Prompt Loop E2E Tests + * + * Scenarios covered: + * - E.6: Encryption prompt reappears after every unencrypted SuperSync sync + * - I.14: Cancelling encryption prompt disables sync entirely + * + * SuperSync requires encryption. After every successful unencrypted sync, + * the encryption dialog reappears with disableClose: true. The only options + * are to set a password or cancel (which disables sync). + * + * Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-encryption-prompt-loop.spec.ts + */ + +test.describe('@supersync Encryption Prompt Loop', () => { + /** + * Scenario E.6: Encryption prompt reappears after every unencrypted SuperSync sync + * + * After a successful sync without encryption, the encryption dialog should + * appear with disableClose: true. This dialog appears after EVERY sync until + * encryption is enabled. + * + * We bypass the prompt initially by setting waitForInitialSync: false, + * then observe the dialog appearing after sync completes. + */ + test('Encryption prompt reappears after every unencrypted SuperSync sync', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(90000); + let clientA: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + + // Set up SuperSync WITHOUT waiting for initial sync and WITHOUT encryption + // This lets us observe the encryption prompt behavior + await clientA.sync.setupSuperSync({ + ...syncConfig, + waitForInitialSync: false, + }); + + // Wait for sync to complete or for encryption dialog to appear + const encryptionDialog = clientA.page.locator('dialog-enable-encryption'); + const syncCheck = clientA.sync.syncCheckIcon; + + // The encryption prompt should appear after the initial sync + const outcome = await Promise.race([ + encryptionDialog + .waitFor({ state: 'visible', timeout: 30000 }) + .then(() => 'encryption_dialog' as const), + syncCheck + .waitFor({ state: 'visible', timeout: 30000 }) + .then(() => 'sync_complete' as const), + ]); + + if (outcome === 'sync_complete') { + // Sync completed first — encryption dialog should appear shortly after + await encryptionDialog.waitFor({ state: 'visible', timeout: 15000 }); + } + + // Verify the encryption dialog is visible + await expect(encryptionDialog).toBeVisible(); + console.log('[E.6] Encryption dialog appeared after unencrypted sync'); + + // The dialog should have disableClose: true (can't click outside to dismiss) + // Verify by checking the backdrop doesn't close the dialog when clicked + // Instead, just verify it's visible and has the expected elements + const cancelBtn = encryptionDialog.locator('button:has-text("Cancel")'); + const enableBtn = encryptionDialog.locator( + 'button[mat-flat-button]:has-text("Enable")', + ); + await expect(cancelBtn).toBeVisible(); + await expect(enableBtn).toBeVisible(); + + console.log( + '[E.6] ✓ Encryption dialog with Cancel/Enable options appeared after sync', + ); + } finally { + if (clientA) await closeClient(clientA); + } + }); + + /** + * Scenario I.14: Cancelling encryption prompt disables sync entirely + * + * When the encryption prompt appears and the user clicks Cancel, + * sync should be disabled (isEnabled: false) and no further syncs occur. + */ + test('Cancelling encryption prompt disables sync entirely', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(90000); + let clientA: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + + // Set up SuperSync WITHOUT waiting for initial sync and WITHOUT encryption + await clientA.sync.setupSuperSync({ + ...syncConfig, + waitForInitialSync: false, + }); + + // Wait for the encryption dialog to appear + const encryptionDialog = clientA.page.locator('dialog-enable-encryption'); + + // Wait for sync to happen first, then dialog should appear + const outcome = await Promise.race([ + encryptionDialog + .waitFor({ state: 'visible', timeout: 30000 }) + .then(() => 'encryption_dialog' as const), + clientA.sync.syncCheckIcon + .waitFor({ state: 'visible', timeout: 30000 }) + .then(() => 'sync_complete' as const), + ]); + + if (outcome === 'sync_complete') { + await encryptionDialog.waitFor({ state: 'visible', timeout: 15000 }); + } + + await expect(encryptionDialog).toBeVisible(); + console.log('[I.14] Encryption dialog appeared'); + + // Click Cancel to disable sync + const cancelBtn = encryptionDialog.locator('button:has-text("Cancel")'); + await cancelBtn.click(); + + // Wait for dialog to close + await encryptionDialog.waitFor({ state: 'hidden', timeout: 10000 }); + console.log('[I.14] Cancel clicked, dialog closed'); + + // Verify sync is disabled — the sync button should not show + // the check/spinner/error state anymore (sync is off) + await expect + .poll(() => clientA.sync.isSyncEnabled(), { timeout: 10000 }) + .toBe(false); + + console.log('[I.14] ✓ Sync disabled after cancelling encryption prompt'); + } finally { + if (clientA) await closeClient(clientA); + } + }); +}); diff --git a/e2e/tests/sync/supersync-error-scenarios.spec.ts b/e2e/tests/sync/supersync-error-scenarios.spec.ts new file mode 100644 index 0000000000..dc75c9c77c --- /dev/null +++ b/e2e/tests/sync/supersync-error-scenarios.spec.ts @@ -0,0 +1,481 @@ +import { test, expect } from '../../fixtures/supersync.fixture'; +import { + createTestUser, + getSuperSyncConfig, + createSimulatedClient, + closeClient, + waitForTask, + type SimulatedE2EClient, +} from '../../utils/supersync-helpers'; + +/** + * SuperSync Error Scenarios E2E Tests + * + * Tests error handling paths using Playwright route interception to simulate + * server responses that are difficult to trigger naturally. + * + * Scenarios covered: + * - B.3: Validation error permanently rejects op + * - B.4: Payload too large shows alert dialog + * - G.5: Duplicate operation silently marked as synced + * - G.7: Schema version mismatch returns handled error + * - G.8: Failed operation migration skips op + * + * Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-error-scenarios.spec.ts + */ + +test.describe('@supersync Error Scenarios', () => { + /** + * Scenario B.3: Validation error permanently rejects op and shows error status + * + * When the server rejects an op with VALIDATION_ERROR, the op should be + * marked as permanently rejected (not retried) and sync status should show ERROR. + */ + test('Validation error permanently rejects op and shows error status', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(90000); + let clientA: SimulatedE2EClient | null = null; + const state = { interceptUpload: true }; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + // Create a task (generates pending ops) + const taskName = `ValidationErr-${testRunId}`; + await clientA.workView.addTask(taskName); + await waitForTask(clientA.page, taskName); + + // Intercept the upload to return a VALIDATION_ERROR rejection + await clientA.page.route('**/api/sync/ops', async (route) => { + if (state.interceptUpload && route.request().method() === 'POST') { + state.interceptUpload = false; + console.log('[Test] Simulating VALIDATION_ERROR rejection'); + + // Get the request body to extract op IDs + const body = route.request().postDataJSON(); + const ops = body?.ops || []; + const rejectedOps = ops.map((op: { id: string }) => ({ + opId: op.id, + error: 'Invalid entity structure', + errorCode: 'VALIDATION_ERROR', + })); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + piggybacked: [], + rejectedOps, + latestSeq: 1, + }), + }); + } else { + await route.continue(); + } + }); + + // Trigger sync — the upload should get VALIDATION_ERROR + try { + await clientA.sync.triggerSync(); + await clientA.page.waitForTimeout(3000); + } catch { + // Expected — triggerSync may throw on error state + } + + // Remove interception + await clientA.page.unroute('**/api/sync/ops'); + + // Verify sync shows error status (permanentRejectionCount > 0 → ERROR) + const hasError = await clientA.sync.hasSyncError(); + expect(hasError).toBe(true); + + // Sync again — the rejected op should NOT be retried + // (it should sync successfully since the rejected op is skipped) + await clientA.sync.syncAndWait(); + + console.log( + '[ValidationError] Validation error correctly caused error status and op was not retried', + ); + } finally { + if (clientA) { + await clientA.page.unroute('**/api/sync/ops').catch(() => {}); + await closeClient(clientA); + } + } + }); + + /** + * Scenario B.4: Payload too large shows alert dialog + * + * When the server returns 413, an alert dialog should appear. + */ + test('Payload too large shows alert dialog', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(60000); + let clientA: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + // Set up dialog handler to capture alert + let alertShown = false; + let alertMessage = ''; + clientA.page.on('dialog', async (dialog) => { + if (dialog.type() === 'alert') { + alertShown = true; + alertMessage = dialog.message(); + console.log(`[Test] Alert dialog: ${alertMessage}`); + await dialog.accept(); + } + }); + + // Wait for initial sync to complete + await clientA.sync.syncAndWait(); + + // Intercept upload to return 413 + await clientA.page.route('**/api/sync/ops', async (route) => { + if (route.request().method() === 'POST') { + console.log('[Test] Simulating 413 Payload Too Large'); + await route.fulfill({ + status: 413, + contentType: 'application/json', + body: JSON.stringify({ + error: 'Payload too large', + code: 'PAYLOAD_TOO_LARGE', + }), + }); + } else { + await route.continue(); + } + }); + + // Create task and trigger sync + const taskName = `PayloadTooLarge-${testRunId}`; + await clientA.workView.addTask(taskName); + await waitForTask(clientA.page, taskName); + + try { + await clientA.sync.triggerSync(); + // Poll for alert + const alertTimeout = 5000; + const pollInterval = 200; + let elapsed = 0; + while (!alertShown && elapsed < alertTimeout) { + await clientA.page.waitForTimeout(pollInterval); + elapsed += pollInterval; + } + } catch { + console.log('[Test] Sync failed with 413 as expected'); + } + + // Verify alert was shown with appropriate message + expect(alertShown).toBe(true); + expect(alertMessage.length).toBeGreaterThan(0); + + // Task should still exist locally (not lost) + await waitForTask(clientA.page, taskName); + + console.log('[PayloadTooLarge] Alert dialog shown for 413 response'); + } finally { + if (clientA) { + await clientA.page.unroute('**/api/sync/ops').catch(() => {}); + await closeClient(clientA); + } + } + }); + + /** + * Scenario G.5: Duplicate operation is silently marked as synced + * + * When the server rejects an op as DUPLICATE_OPERATION, the client should + * mark it as synced (not show an error). This handles the case where the + * client successfully uploaded but didn't receive the acknowledgment. + */ + test('Duplicate operation is silently marked as synced', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(90000); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + const state = { returnDuplicate: false }; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + // Create task and sync it successfully first + const taskName = `Duplicate-${testRunId}`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); + + // Create another task (generates new pending ops) + const taskName2 = `Duplicate2-${testRunId}`; + await clientA.workView.addTask(taskName2); + await waitForTask(clientA.page, taskName2); + + // Intercept the next upload to return DUPLICATE_OPERATION + state.returnDuplicate = true; + await clientA.page.route('**/api/sync/ops', async (route) => { + if (state.returnDuplicate && route.request().method() === 'POST') { + state.returnDuplicate = false; + console.log('[Test] Simulating DUPLICATE_OPERATION rejection'); + + const body = route.request().postDataJSON(); + const ops = body?.ops || []; + const rejectedOps = ops.map((op: { id: string }) => ({ + opId: op.id, + error: 'Duplicate operation', + errorCode: 'DUPLICATE_OPERATION', + })); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + piggybacked: [], + rejectedOps, + latestSeq: 2, + }), + }); + } else { + await route.continue(); + } + }); + + // Trigger sync — duplicate rejection should be handled silently + try { + await clientA.sync.triggerSync(); + await clientA.page.waitForTimeout(2000); + } catch { + // May or may not throw + } + + // Remove interception + await clientA.page.unroute('**/api/sync/ops'); + + // Verify no error shown — duplicate should be handled silently + // After removing the route, the next sync should succeed + await clientA.sync.syncAndWait(); + const hasError = await clientA.sync.hasSyncError(); + expect(hasError).toBe(false); + + // Verify tasks still exist + await waitForTask(clientA.page, taskName); + + // Verify with Client B that the task made it to the server + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, taskName); + + console.log('[DuplicateOp] Duplicate operation handled silently without error'); + } finally { + if (clientA) { + await clientA.page.unroute('**/api/sync/ops').catch(() => {}); + await closeClient(clientA); + } + if (clientB) await closeClient(clientB); + } + }); + + /** + * Scenario G.7: Schema version mismatch returns handled error + * + * When downloaded ops have a modelVersion higher than the client's, + * the client should log a warning and return HANDLED_ERROR without crashing. + */ + test('Schema version mismatch returns handled error without crash', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(90000); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + const state = { injectFutureSchemaOps: true }; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + // Client A creates real data + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + const taskName = `Schema-${testRunId}`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); + + // Client B will receive ops with future schema version + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + + // Intercept download to inject ops with a very high schema version + await clientB.page.route('**/api/sync/ops/**', async (route) => { + if (route.request().method() === 'GET' && state.injectFutureSchemaOps) { + state.injectFutureSchemaOps = false; + console.log('[Test] Injecting ops with future schema version'); + + // Get real response and modify it + const response = await route.fetch(); + const json = await response.json(); + + // Modify all ops to have a very high schema version + if (json.ops) { + for (const op of json.ops) { + op.schemaVersion = 99999; + } + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(json), + }); + } else { + await route.continue(); + } + }); + + // Client B syncs — should handle the schema mismatch gracefully + try { + await clientB.sync.triggerSync(); + await clientB.page.waitForTimeout(3000); + } catch { + // May or may not throw depending on error handling + } + + // Remove interception and retry with real data + await clientB.page.unroute('**/api/sync/ops/**'); + await clientB.sync.syncAndWait(); + + // Verify Client B didn't crash and can still sync + await waitForTask(clientB.page, taskName); + const hasError = await clientB.sync.hasSyncError(); + expect(hasError).toBe(false); + + console.log( + '[SchemaVersionMismatch] Client handled future schema version without crash', + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) { + await clientB.page.unroute('**/api/sync/ops/**').catch(() => {}); + await closeClient(clientB); + } + } + }); + + /** + * Scenario G.8: Failed operation migration skips op and other ops still apply + * + * When a downloaded op has a corrupted/unmigrateable structure, + * it should be skipped and other valid ops should still be applied. + */ + test('Failed operation migration skips corrupted op, applies others', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(90000); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + const state = { injectCorruptedOp: true }; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + // Client A creates real data + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + const taskName = `MigrationFail-${testRunId}`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); + + // Client B will receive ops including one corrupted one + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + + // Intercept download to inject a corrupted op alongside valid ones + await clientB.page.route('**/api/sync/ops/**', async (route) => { + if (route.request().method() === 'GET' && state.injectCorruptedOp) { + state.injectCorruptedOp = false; + console.log('[Test] Injecting corrupted op into download response'); + + const response = await route.fetch(); + const json = await response.json(); + + // Insert a corrupted op before the valid ones + if (json.ops && json.ops.length > 0) { + const corruptedOp = { + id: 'corrupted-migration-op', + opType: 'UPD', + entityType: 'TASK', + entityId: 'nonexistent-entity', + actionType: '[Task] CORRUPTED_ACTION', + payload: { title: undefined, __broken: true }, + vectorClock: { broken_client: 1 }, + timestamp: Date.now(), + schemaVersion: 0, // Very old schema, likely to fail migration + clientId: 'broken-client', + }; + json.ops.unshift(corruptedOp); + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(json), + }); + } else { + await route.continue(); + } + }); + + // Client B syncs — corrupted op should be skipped, valid ops applied + await clientB.sync.syncAndWait(); + + // Remove interception + await clientB.page.unroute('**/api/sync/ops/**'); + + // Verify the valid task was still received despite the corrupted op + await waitForTask(clientB.page, taskName); + + // Verify Client B is healthy and can sync again + await clientB.sync.syncAndWait(); + const hasError = await clientB.sync.hasSyncError(); + expect(hasError).toBe(false); + + console.log( + '[MigrationFailure] Corrupted op skipped, valid ops applied successfully', + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) { + await clientB.page.unroute('**/api/sync/ops/**').catch(() => {}); + await closeClient(clientB); + } + } + }); +}); diff --git a/e2e/tests/sync/supersync-import-clean-slate.spec.ts b/e2e/tests/sync/supersync-import-clean-slate.spec.ts index d4c41fa808..21621af0a9 100644 --- a/e2e/tests/sync/supersync-import-clean-slate.spec.ts +++ b/e2e/tests/sync/supersync-import-clean-slate.spec.ts @@ -550,4 +550,137 @@ test.describe('@supersync @cleanslate Import Clean Slate Semantics', () => { if (clientB) await closeClient(clientB); } }); + + /** + * Scenario D.4: Remote ops concurrent with accepted remote import are silently filtered + * + * When Client B imports a backup and Client A accepts it (USE_REMOTE), + * any remaining old ops from before the import should be silently filtered + * on subsequent syncs — no dialog, no errors. + * + * Actions: + * 1. Client A creates tasks, syncs + * 2. Client B imports backup, syncs (creates SYNC_IMPORT) + * 3. Client A syncs, gets SYNC_IMPORT, accepts remote import (USE_REMOTE) + * 4. Client A syncs again — old ops should be silently filtered + * + * Verify: + * - No dialog on the second sync after accepting import + * - Sync completes successfully (IN_SYNC) + * - Only imported data remains + */ + test('Remote ops concurrent with accepted remote import are silently filtered (D.4)', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + const appUrl = baseURL || 'http://localhost:4242'; + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + // ============ PHASE 1: Client A creates tasks ============ + console.log('[D.4] Phase 1: Client A creates tasks'); + + clientA = await createSimulatedClient(browser, appUrl, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + const taskAOld = `Task-A-Old-${uniqueId}`; + await clientA.workView.addTask(taskAOld); + await clientA.sync.syncAndWait(); + console.log(`[D.4] Client A created and synced: ${taskAOld}`); + + // ============ PHASE 2: Client B imports backup ============ + console.log('[D.4] Phase 2: Client B imports backup'); + + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + + // Perform import on Client B + const importPage = new ImportPage(clientB.page); + await importPage.navigateToImportPage(); + const backupPath = ImportPage.getFixturePath('test-backup.json'); + await importPage.importBackupFile(backupPath); + console.log('[D.4] Client B imported backup'); + + // Close and re-open Client B to pick up imported data + await clientB.page.close(); + clientB.page = await clientB.context.newPage(); + await clientB.page.goto('/'); + clientB.workView = new WorkViewPage(clientB.page, `B-${testRunId}`); + clientB.sync = new SuperSyncPage(clientB.page); + await waitForAppReady(clientB.page, { ensureRoute: false }); + + // Configure sync WITHOUT waiting for initial sync + await clientB.sync.setupSuperSync({ ...syncConfig, waitForInitialSync: false }); + + // Wait for sync import conflict dialog or sync completion + const syncImportDialog = clientB.sync.syncImportConflictDialog; + const syncResult = await Promise.race([ + syncImportDialog + .waitFor({ state: 'visible', timeout: 30000 }) + .then(() => 'dialog' as const), + clientB.sync.syncCheckIcon + .waitFor({ state: 'visible', timeout: 30000 }) + .then(() => 'complete' as const), + ]); + + if (syncResult === 'dialog') { + await clientB.sync.syncImportUseLocalBtn.click(); + await syncImportDialog.waitFor({ state: 'hidden', timeout: 5000 }); + await clientB.sync.syncCheckIcon.waitFor({ state: 'visible', timeout: 30000 }); + } + + // Client B syncs to upload SYNC_IMPORT + await clientB.sync.syncAndWait(); + console.log('[D.4] Client B synced (SYNC_IMPORT uploaded)'); + + // ============ PHASE 3: Client A syncs and accepts remote import ============ + console.log('[D.4] Phase 3: Client A syncs and accepts remote import'); + + // Client A syncs — should get SYNC_IMPORT, may show conflict dialog + // Use syncAndWait which handles dialogs automatically (uses remote by default) + await clientA.sync.syncAndWait(); + console.log('[D.4] Client A accepted remote import'); + + // Wait for state to settle + await clientA.page.waitForTimeout(1000); + + // ============ PHASE 4: Client A syncs AGAIN — old ops should be silently filtered ============ + console.log( + '[D.4] Phase 4: Client A syncs again (old ops should be silently filtered)', + ); + + // This is the CRITICAL sync — any remaining old ops from before the import + // should be silently filtered by SyncImportFilterService with no dialog + await clientA.sync.syncAndWait(); + + // Verify no error + const hasError = await clientA.sync.hasSyncError(); + expect(hasError).toBe(false); + + // Verify sync is IN_SYNC + await expect(clientA.sync.syncCheckIcon).toBeVisible({ timeout: 5000 }); + + // Navigate to work view + await clientA.page.goto('/#/work-view'); + await clientA.page.waitForLoadState('networkidle'); + + // Verify imported task is present + await waitForTask(clientA.page, 'E2E Import Test - Active Task With Subtask'); + + // Verify old task is gone (clean slate) + const oldTaskOnA = clientA.page.locator(`task:has-text("${taskAOld}")`); + await expect(oldTaskOnA).not.toBeVisible({ timeout: 5000 }); + + console.log('[D.4] ✓ Old ops silently filtered after accepting remote import'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); }); diff --git a/e2e/tests/sync/supersync-no-op-sync.spec.ts b/e2e/tests/sync/supersync-no-op-sync.spec.ts new file mode 100644 index 0000000000..2ff658bc5e --- /dev/null +++ b/e2e/tests/sync/supersync-no-op-sync.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from '../../fixtures/supersync.fixture'; +import { + createTestUser, + getSuperSyncConfig, + createSimulatedClient, + closeClient, + getTaskCount, + type SimulatedE2EClient, +} from '../../utils/supersync-helpers'; + +/** + * SuperSync No-Op Sync E2E Tests + * + * Scenario A.3: No changes on either side. + * When sync fires but there are no new ops anywhere, the client should + * update lastServerSeq and remain in IN_SYNC status without errors. + * + * Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-no-op-sync.spec.ts + */ + +test.describe('@supersync No-Op Sync', () => { + /** + * Scenario A.3: No-op sync round-trip updates seq without errors + * + * Actions: + * 1. Client A sets up SuperSync + * 2. Client A creates a task and syncs (initial data) + * 3. Client A syncs again with no changes on either side + * + * Verify: + * - No error after the no-op sync + * - Sync status is IN_SYNC (check icon visible) + * - Task count is unchanged + */ + test('No-op sync round-trip updates seq without errors', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + // Create a task so there's some data on the server + const taskName = `NoOp-Task-${testRunId}`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); + + // Record task count before no-op sync + const countBefore = await getTaskCount(clientA); + + // No-op sync: no changes on either side + await clientA.sync.syncAndWait(); + + // Verify no error + const hasError = await clientA.sync.hasSyncError(); + expect(hasError).toBe(false); + + // Verify sync shows success (check icon) + await expect(clientA.sync.syncCheckIcon).toBeVisible({ timeout: 5000 }); + + // Verify task count unchanged + const countAfter = await getTaskCount(clientA); + expect(countAfter).toBe(countBefore); + + // Do one more no-op sync to be thorough + await clientA.sync.syncAndWait(); + const hasErrorAfterSecond = await clientA.sync.hasSyncError(); + expect(hasErrorAfterSecond).toBe(false); + + console.log('[NoOpSync] No-op sync completed without errors'); + } finally { + if (clientA) await closeClient(clientA); + } + }); +}); diff --git a/e2e/tests/sync/supersync-provider-switch-to-supersync.spec.ts b/e2e/tests/sync/supersync-provider-switch-to-supersync.spec.ts new file mode 100644 index 0000000000..541f16d1b2 --- /dev/null +++ b/e2e/tests/sync/supersync-provider-switch-to-supersync.spec.ts @@ -0,0 +1,187 @@ +import { test, expect } from '../../fixtures/supersync.fixture'; +import { + createTestUser, + getSuperSyncConfig, + createSimulatedClient, + closeClient, + waitForTask, + type SimulatedE2EClient, +} from '../../utils/supersync-helpers'; +import { + WEBDAV_CONFIG_TEMPLATE, + createSyncFolder, + generateSyncFolderName, + setupSyncClient, + waitForSyncComplete, + closeContextsSafely, +} from '../../utils/sync-helpers'; +import { SyncPage } from '../../pages/sync.page'; +import { SuperSyncPage } from '../../pages/supersync.page'; +import { WorkViewPage } from '../../pages/work-view.page'; +import { waitForStatePersistence } from '../../utils/waits'; + +/** + * SuperSync Provider Switch (WebDAV → SuperSync) E2E Tests + * + * Scenario I.7: Switching from WebDAV to SuperSync migrates data via SYNC_IMPORT + * + * When a client that has been syncing to WebDAV switches to SuperSync, + * the server migration logic detects the empty SuperSync server and the + * client's existing synced ops, creating a SYNC_IMPORT to migrate data. + * Another client joining the SuperSync account should receive all tasks. + * + * Prerequisites: + * - super-sync-server running on localhost:1901 with TEST_MODE=true + * - WebDAV server running on localhost:2345 + * - Frontend running on localhost:4242 + * + * Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-provider-switch-to-supersync.spec.ts + */ + +/** + * Check if the WebDAV server is available. + */ +const isWebDAVAvailable = async (): Promise => { + try { + const response = await fetch('http://localhost:2345', { + method: 'OPTIONS', + signal: AbortSignal.timeout(3000), + }); + return response.ok || response.status === 200 || response.status === 207; + } catch { + return false; + } +}; + +test.describe('@supersync Provider Switch WebDAV to SuperSync', () => { + /** + * Scenario I.7: Switching from WebDAV to SuperSync migrates data via SYNC_IMPORT + * + * Actions: + * 1. Client A sets up WebDAV sync, creates tasks, syncs to WebDAV + * 2. Client A switches to SuperSync (new empty server) on the same page + * 3. Server migration detects empty server + client has synced ops + * + * Verify: + * - Tasks are preserved after provider switch + * - Client B joining SuperSync receives all tasks + */ + test('Switching from WebDAV to SuperSync migrates data via SYNC_IMPORT', async ({ + browser, + baseURL, + testRunId, + }, testInfo) => { + test.setTimeout(180000); + + // Skip if WebDAV server is not available + const webdavAvailable = await isWebDAVAvailable(); + testInfo.skip(!webdavAvailable, 'WebDAV server not running on localhost:2345'); + + const appUrl = baseURL || 'http://localhost:4242'; + let contextA: Awaited> | null = null; + let clientB: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + // Create WebDAV sync folder + const syncFolderName = generateSyncFolderName(`e2e-to-supersync-${testRunId}`); + const webdavRequestContext = await browser.newContext(); + try { + await createSyncFolder(webdavRequestContext.request, syncFolderName); + } finally { + await webdavRequestContext.close(); + } + + const webdavConfig = { + ...WEBDAV_CONFIG_TEMPLATE, + syncFolderPath: `/${syncFolderName}`, + }; + + // ============ PHASE 1: Set up WebDAV and create tasks ============ + console.log('[I.7] Phase 1: Set up WebDAV sync and create tasks'); + + // Use setupSyncClient which auto-accepts confirm dialogs + const { context, page: pageA } = await setupSyncClient(browser, appUrl); + contextA = context; + const syncPageA = new SyncPage(pageA); + const workViewPageA = new WorkViewPage(pageA); + + await workViewPageA.waitForTaskList(); + await syncPageA.setupWebdavSync(webdavConfig); + await expect(syncPageA.syncBtn).toBeVisible(); + console.log('[I.7] Client A: WebDAV sync configured'); + + // Create tasks + const task1 = `WebDAV-To-SS-1-${testRunId}`; + const task2 = `WebDAV-To-SS-2-${testRunId}`; + await workViewPageA.addTask(task1); + await workViewPageA.addTask(task2); + await expect(pageA.locator('task')).toHaveCount(2); + console.log(`[I.7] Client A: Created tasks: ${task1}, ${task2}`); + + // Sync to WebDAV + await waitForStatePersistence(pageA); + await syncPageA.triggerSync(); + await waitForSyncComplete(pageA, syncPageA); + console.log('[I.7] Client A: Synced to WebDAV'); + + // ============ PHASE 2: Switch to SuperSync on the same page ============ + console.log('[I.7] Phase 2: Switching to SuperSync (same browser context)'); + + // Use SuperSyncPage on the SAME page to switch providers + // setupSuperSync right-clicks the sync button to open settings, + // which works even when another provider is already configured + const superSyncPageA = new SuperSyncPage(pageA); + await superSyncPageA.setupSuperSync({ + ...syncConfig, + isEncryptionEnabled: true, + password: 'test-password-123', + }); + console.log('[I.7] Client A: Switched to SuperSync with encryption'); + + // Sync to upload all data to SuperSync + await superSyncPageA.syncAndWait(); + console.log('[I.7] Client A: Synced to SuperSync'); + + // Verify tasks still exist after the switch + await expect(pageA.locator('task', { hasText: task1 })).toBeVisible({ + timeout: 10000, + }); + await expect(pageA.locator('task', { hasText: task2 })).toBeVisible({ + timeout: 10000, + }); + console.log('[I.7] Client A: Tasks preserved after switch to SuperSync'); + + // ============ PHASE 3: Client B joins SuperSync ============ + console.log('[I.7] Phase 3: Client B joins SuperSync'); + + clientB = await createSimulatedClient(browser, appUrl, 'B', testRunId); + await clientB.sync.setupSuperSync({ + ...syncConfig, + isEncryptionEnabled: true, + password: 'test-password-123', + }); + await clientB.sync.syncAndWait(); + + // Verify Client B received the tasks + await waitForTask(clientB.page, task1); + await waitForTask(clientB.page, task2); + console.log('[I.7] Client B: Received tasks from SuperSync'); + + // No errors on either client + const hasErrorA = await superSyncPageA.hasSyncError(); + const hasErrorB = await clientB.sync.hasSyncError(); + expect(hasErrorA).toBe(false); + expect(hasErrorB).toBe(false); + + console.log( + '[I.7] ✓ WebDAV → SuperSync provider switch migrated data successfully', + ); + } finally { + if (contextA) await closeContextsSafely(contextA); + if (clientB) await closeClient(clientB); + } + }); +}); diff --git a/e2e/tests/sync/supersync-reenable-and-account-switch.spec.ts b/e2e/tests/sync/supersync-reenable-and-account-switch.spec.ts new file mode 100644 index 0000000000..6fed28b3bb --- /dev/null +++ b/e2e/tests/sync/supersync-reenable-and-account-switch.spec.ts @@ -0,0 +1,185 @@ +import { test, expect } from '../../fixtures/supersync.fixture'; +import { + createTestUser, + getSuperSyncConfig, + createSimulatedClient, + closeClient, + waitForTask, + type SimulatedE2EClient, +} from '../../utils/supersync-helpers'; +import { expectTaskVisible } from '../../utils/supersync-assertions'; + +/** + * SuperSync Re-enable and Account Switch E2E Tests + * + * Scenarios covered: + * - I.5: Re-enabling sync after disable resumes from stored lastServerSeq + * - I.6: Switching SuperSync accounts syncs to new server + * + * Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-reenable-and-account-switch.spec.ts + */ + +test.describe('@supersync Re-enable and Account Switch', () => { + /** + * Scenario I.5: Re-enabling sync after disable resumes from stored lastServerSeq + * + * Actions: + * 1. Client A sets up SuperSync, creates tasks, syncs + * 2. Client A disables sync + * 3. Client A creates more tasks while offline + * 4. Client A re-enables SuperSync with same config + * + * Verify: + * - New tasks upload successfully + * - Existing tasks preserved + * - Status is IN_SYNC + */ + test('Re-enabling sync after disable resumes from stored lastServerSeq', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(120000); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + // ============ PHASE 1: Setup and initial sync ============ + console.log('[I.5] Phase 1: Setup and initial sync'); + + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + const taskBefore = `ReEnable-Before-${testRunId}`; + await clientA.workView.addTask(taskBefore); + await clientA.sync.syncAndWait(); + console.log(`[I.5] Created and synced: ${taskBefore}`); + + // ============ PHASE 2: Disable sync ============ + console.log('[I.5] Phase 2: Disabling sync'); + + await clientA.sync.disableSync(); + console.log('[I.5] Sync disabled'); + + // ============ PHASE 3: Create tasks while "offline" ============ + console.log('[I.5] Phase 3: Creating tasks while sync is disabled'); + + const taskOffline = `ReEnable-Offline-${testRunId}`; + await clientA.workView.addTask(taskOffline); + await waitForTask(clientA.page, taskOffline); + console.log(`[I.5] Created while disabled: ${taskOffline}`); + + // ============ PHASE 4: Re-enable sync ============ + console.log('[I.5] Phase 4: Re-enabling sync'); + + await clientA.sync.setupSuperSync(syncConfig); + + // Sync to upload offline tasks + await clientA.sync.syncAndWait(); + console.log('[I.5] Re-enabled and synced'); + + // ============ PHASE 5: Verify ============ + console.log('[I.5] Phase 5: Verifying'); + + // Both tasks should still exist + await expectTaskVisible(clientA, taskBefore); + await expectTaskVisible(clientA, taskOffline); + + // No error + const hasError = await clientA.sync.hasSyncError(); + expect(hasError).toBe(false); + + // Verify with Client B that offline tasks uploaded + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + await clientB.sync.syncAndWait(); + + await waitForTask(clientB.page, taskBefore); + await waitForTask(clientB.page, taskOffline); + + console.log('[I.5] ✓ Re-enabling sync resumed correctly'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); + + /** + * Scenario I.6: Switching SuperSync accounts syncs to new server + * + * Actions: + * 1. Client A sets up SuperSync with user1, creates tasks, syncs + * 2. Client A switches to user2 (different token/account) + * 3. Client A syncs to new account — server migration creates SYNC_IMPORT + * + * Verify: + * - Tasks are available on the new account (via SYNC_IMPORT) + * - Client B joining new account receives the tasks + */ + test('Switching SuperSync accounts syncs to new server', async ({ + browser, + baseURL, + testRunId, + }) => { + test.setTimeout(120000); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + + try { + // Create two separate user accounts + const user1 = await createTestUser(`${testRunId}-user1`); + const user2 = await createTestUser(`${testRunId}-user2`); + const syncConfig1 = getSuperSyncConfig(user1); + const syncConfig2 = getSuperSyncConfig(user2); + + // ============ PHASE 1: Setup with user1 ============ + console.log('[I.6] Phase 1: Setup with user1'); + + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig1); + + const taskName = `AcctSwitch-${testRunId}`; + await clientA.workView.addTask(taskName); + await clientA.sync.syncAndWait(); + console.log(`[I.6] Created and synced to user1: ${taskName}`); + + // ============ PHASE 2: Switch to user2 ============ + console.log('[I.6] Phase 2: Switching to user2'); + + // Reconfigure sync with user2 credentials + await clientA.sync.setupSuperSync(syncConfig2); + console.log('[I.6] Reconfigured with user2'); + + // Sync to new account — server migration should create SYNC_IMPORT + // because the new server is empty but client has synced ops + await clientA.sync.syncAndWait(); + console.log('[I.6] Synced to user2'); + + // ============ PHASE 3: Verify task is on new account ============ + console.log('[I.6] Phase 3: Verifying task on new account'); + + // Task should still be visible on Client A + await expectTaskVisible(clientA, taskName); + + // No error + const hasError = await clientA.sync.hasSyncError(); + expect(hasError).toBe(false); + + // Verify with Client B joining user2's account + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig2); + await clientB.sync.syncAndWait(); + + await waitForTask(clientB.page, taskName); + console.log('[I.6] ✓ Client B on user2 received the task'); + + console.log('[I.6] ✓ Account switch completed successfully'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); +}); diff --git a/e2e/tests/sync/supersync-server-migration-abort.spec.ts b/e2e/tests/sync/supersync-server-migration-abort.spec.ts new file mode 100644 index 0000000000..e6a093ee5b --- /dev/null +++ b/e2e/tests/sync/supersync-server-migration-abort.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from '../../fixtures/supersync.fixture'; +import { + createTestUser, + getSuperSyncConfig, + createSimulatedClient, + closeClient, + waitForTask, + type SimulatedE2EClient, +} from '../../utils/supersync-helpers'; +import { + expectTaskOnAllClients, + expectEqualTaskCount, +} from '../../utils/supersync-assertions'; + +/** + * SuperSync Server Migration Abort E2E Tests + * + * Scenario F.2: Migration aborted when server is no longer empty. + * + * When a client that has synced ops from a previous provider connects to a + * SuperSync server that already has data (from another client), the server + * migration check should find the server non-empty and skip the SYNC_IMPORT. + * Instead, the client should do a normal sync. + * + * Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-server-migration-abort.spec.ts + */ + +test.describe('@supersync Server Migration Abort', () => { + /** + * Scenario F.2: Migration aborts when server is no longer empty + * + * Actions: + * 1. Client A sets up SuperSync, creates tasks, syncs (populates server) + * 2. Client B sets up SuperSync with the same account + * 3. Client B syncs — server is NOT empty, so no migration/SYNC_IMPORT + * 4. Client B does normal sync instead (downloads A's ops) + * + * Verify: + * - Client B receives Client A's tasks via normal sync (no SYNC_IMPORT) + * - Both clients converge to the same state + * - No error on either client + */ + test('Migration aborts when server is no longer empty', async ({ + browser, + baseURL, + testRunId, + }) => { + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + + // ============ PHASE 1: Client A populates the server ============ + console.log('[F.2] Phase 1: Client A populates the server'); + + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync(syncConfig); + + const taskA1 = `Migration-A1-${testRunId}`; + const taskA2 = `Migration-A2-${testRunId}`; + await clientA.workView.addTask(taskA1); + await clientA.workView.addTask(taskA2); + await clientA.sync.syncAndWait(); + console.log(`[F.2] Client A created and synced: ${taskA1}, ${taskA2}`); + + // ============ PHASE 2: Client B joins (server already has data) ============ + console.log('[F.2] Phase 2: Client B joins (server already has data)'); + + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync(syncConfig); + + // Client B syncs — server is NOT empty (A already populated it) + // Migration should be aborted, normal sync occurs instead + await clientB.sync.syncAndWait(); + console.log('[F.2] Client B synced (migration should have been aborted)'); + + // ============ PHASE 3: Verify convergence ============ + console.log('[F.2] Phase 3: Verifying both clients converge'); + + // Client B should have received A's tasks via normal sync + await waitForTask(clientB.page, taskA1); + await waitForTask(clientB.page, taskA2); + + // Verify both clients have the same tasks + await expectTaskOnAllClients([clientA, clientB], taskA1); + await expectTaskOnAllClients([clientA, clientB], taskA2); + await expectEqualTaskCount([clientA, clientB]); + + // Verify no errors on either client + const hasErrorA = await clientA.sync.hasSyncError(); + const hasErrorB = await clientB.sync.hasSyncError(); + expect(hasErrorA).toBe(false); + expect(hasErrorB).toBe(false); + + // ============ PHASE 4: Verify bidirectional sync works ============ + console.log('[F.2] Phase 4: Verifying bidirectional sync'); + + // Client B creates a task + const taskB1 = `Migration-B1-${testRunId}`; + await clientB.workView.addTask(taskB1); + await clientB.sync.syncAndWait(); + + // Client A syncs and receives it + await clientA.sync.syncAndWait(); + await waitForTask(clientA.page, taskB1); + + console.log('[F.2] ✓ Migration aborted, normal sync worked correctly'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); +}); diff --git a/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.spec.ts b/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.spec.ts index c99e6804b8..a0d2291903 100644 --- a/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.spec.ts +++ b/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.spec.ts @@ -8,8 +8,9 @@ import { } from './dialog-change-encryption-password.component'; import { EncryptionPasswordChangeService } from '../encryption-password-change.service'; import { SnackService } from '../../../core/snack/snack.service'; -import { EncryptionDisableService } from '../encryption-disable.service'; +import { SuperSyncEncryptionToggleService } from '../supersync-encryption-toggle.service'; import { FileBasedEncryptionService } from '../file-based-encryption.service'; +import { SyncWrapperService } from '../sync-wrapper.service'; describe('DialogChangeEncryptionPasswordComponent', () => { let component: DialogChangeEncryptionPasswordComponent; @@ -20,7 +21,8 @@ describe('DialogChangeEncryptionPasswordComponent', () => { let mockEncryptionPasswordChangeService: jasmine.SpyObj; let mockFileBasedEncryptionService: jasmine.SpyObj; let mockSnackService: jasmine.SpyObj; - let mockEncryptionDisableService: jasmine.SpyObj; + let mockEncryptionToggleService: jasmine.SpyObj; + let mockSyncWrapperService: jasmine.SpyObj; beforeEach(async () => { mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); @@ -30,12 +32,19 @@ describe('DialogChangeEncryptionPasswordComponent', () => { ); mockFileBasedEncryptionService = jasmine.createSpyObj('FileBasedEncryptionService', [ 'changePassword', + 'disableEncryption', ]); mockSnackService = jasmine.createSpyObj('SnackService', ['open']); - mockEncryptionDisableService = jasmine.createSpyObj('EncryptionDisableService', [ - 'disableEncryption', - 'disableEncryptionForFileBased', + mockEncryptionToggleService = jasmine.createSpyObj( + 'SuperSyncEncryptionToggleService', + ['disableEncryption'], + ); + mockSyncWrapperService = jasmine.createSpyObj('SyncWrapperService', [ + 'runWithSyncBlocked', ]); + mockSyncWrapperService.runWithSyncBlocked.and.callFake( + async (operation: () => Promise): Promise => operation(), + ); await TestBed.configureTestingModule({ imports: [ @@ -54,7 +63,14 @@ describe('DialogChangeEncryptionPasswordComponent', () => { useValue: mockFileBasedEncryptionService, }, { provide: SnackService, useValue: mockSnackService }, - { provide: EncryptionDisableService, useValue: mockEncryptionDisableService }, + { + provide: SuperSyncEncryptionToggleService, + useValue: mockEncryptionToggleService, + }, + { + provide: SyncWrapperService, + useValue: mockSyncWrapperService, + }, ], }).compileComponents(); @@ -170,7 +186,7 @@ describe('DialogChangeEncryptionPasswordComponent', () => { expect(mockSnackService.open).toHaveBeenCalledWith( jasmine.objectContaining({ type: 'ERROR', - msg: 'Failed to change password: Network error', + translateParams: { message: 'Network error' }, }), ); expect(component.isLoading()).toBe(false); @@ -189,7 +205,7 @@ describe('DialogChangeEncryptionPasswordComponent', () => { expect(mockSnackService.open).toHaveBeenCalledWith( jasmine.objectContaining({ type: 'ERROR', - msg: 'Failed to change password: Unknown error', + translateParams: { message: 'Unknown error' }, }), ); }); diff --git a/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.spec.ts b/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.spec.ts index 6d444c9f2b..197684158d 100644 --- a/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.spec.ts +++ b/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.spec.ts @@ -2,6 +2,7 @@ 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 { of } from 'rxjs'; import { DialogEnterEncryptionPasswordComponent } from './dialog-enter-encryption-password.component'; import { SyncConfigService } from '../sync-config.service'; import { EncryptionPasswordChangeService } from '../encryption-password-change.service'; @@ -73,4 +74,91 @@ describe('DialogEnterEncryptionPasswordComponent', () => { expect(mockSnackService.open).toHaveBeenCalled(); expect(mockDialogRef.close).not.toHaveBeenCalled(); }); + + describe('forceOverwrite', () => { + it('should return early when passwordVal is empty', async () => { + component.passwordVal = ''; + + await component.forceOverwrite(); + + expect(mockMatDialog.open).not.toHaveBeenCalled(); + }); + + it('should return early when isLoading is true', async () => { + component.passwordVal = 'password123'; + component.isLoading.set(true); + + await component.forceOverwrite(); + + expect(mockMatDialog.open).not.toHaveBeenCalled(); + }); + + it('should return early when user cancels the confirm dialog', async () => { + component.passwordVal = 'password123'; + mockMatDialog.open.and.returnValue({ + afterClosed: () => of(false), + } as any); + + await component.forceOverwrite(); + + expect(mockMatDialog.open).toHaveBeenCalled(); + expect(mockEncryptionPasswordChangeService.changePassword).not.toHaveBeenCalled(); + }); + + it('should call changePassword and close with forceOverwrite on confirm', async () => { + component.passwordVal = 'password123'; + mockMatDialog.open.and.returnValue({ + afterClosed: () => of(true), + } as any); + mockEncryptionPasswordChangeService.changePassword.and.returnValue( + Promise.resolve(), + ); + + await component.forceOverwrite(); + + expect(mockEncryptionPasswordChangeService.changePassword).toHaveBeenCalledWith( + 'password123', + { allowUnsyncedOps: true }, + ); + expect(mockDialogRef.close).toHaveBeenCalledWith({ forceOverwrite: true }); + expect(component.isLoading()).toBe(false); + }); + + it('should show error snackbar and reset loading on changePassword failure', async () => { + component.passwordVal = 'password123'; + mockMatDialog.open.and.returnValue({ + afterClosed: () => of(true), + } as any); + mockEncryptionPasswordChangeService.changePassword.and.returnValue( + Promise.reject(new Error('server error')), + ); + + await component.forceOverwrite(); + + expect(mockSnackService.open).toHaveBeenCalledWith( + jasmine.objectContaining({ + type: 'ERROR', + translateParams: { message: 'server error' }, + }), + ); + expect(component.isLoading()).toBe(false); + expect(mockDialogRef.close).not.toHaveBeenCalled(); + }); + }); + + describe('cancel', () => { + it('should close with empty object', () => { + component.cancel(); + + expect(mockDialogRef.close).toHaveBeenCalledWith({}); + }); + + it('should not close when loading', () => { + component.isLoading.set(true); + + component.cancel(); + + expect(mockDialogRef.close).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/imex/sync/encryption-disable.service.spec.ts b/src/app/imex/sync/encryption-disable.service.spec.ts deleted file mode 100644 index e4d8262176..0000000000 --- a/src/app/imex/sync/encryption-disable.service.spec.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { EncryptionDisableService } from './encryption-disable.service'; -import { SnapshotUploadData, SnapshotUploadService } from './snapshot-upload.service'; -import { SyncProviderId } from '../../op-log/sync-providers/provider.const'; -import { - OperationSyncCapable, - SyncProviderServiceInterface, -} from '../../op-log/sync-providers/provider.interface'; -import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model'; -import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service'; -import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service'; -import { StateSnapshotService } from '../../op-log/backup/state-snapshot.service'; -import { VectorClockService } from '../../op-log/sync/vector-clock.service'; -import { CLIENT_ID_PROVIDER } 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'; - -describe('EncryptionDisableService', () => { - let service: EncryptionDisableService; - let mockSnapshotUploadService: jasmine.SpyObj; - let mockWrappedProviderService: jasmine.SpyObj; - let mockProviderManager: jasmine.SpyObj; - let mockStateSnapshotService: jasmine.SpyObj; - let mockVectorClockService: jasmine.SpyObj; - let mockFileBasedAdapter: jasmine.SpyObj; - let mockGlobalConfigService: jasmine.SpyObj; - let mockSyncProvider: jasmine.SpyObj< - SyncProviderServiceInterface & OperationSyncCapable - >; - - const mockExistingCfg: SuperSyncPrivateCfg = { - baseUrl: 'https://test.example.com', - accessToken: 'test-token', - isEncryptionEnabled: true, - encryptKey: 'existing-key', - }; - - beforeEach(() => { - mockSyncProvider = jasmine.createSpyObj('SyncProvider', [ - 'deleteAllData', - 'setPrivateCfg', - ]); - mockSyncProvider.id = SyncProviderId.SuperSync; - mockSyncProvider.deleteAllData.and.resolveTo({ success: true }); - mockSyncProvider.setPrivateCfg.and.resolveTo(); - - mockSnapshotUploadService = jasmine.createSpyObj('SnapshotUploadService', [ - 'gatherSnapshotData', - 'uploadSnapshot', - 'updateLastServerSeq', - ]); - mockSnapshotUploadService.gatherSnapshotData.and.resolveTo({ - syncProvider: mockSyncProvider, - existingCfg: mockExistingCfg, - state: { task: [] }, - vectorClock: { testClient1: 1 }, - clientId: 'testClient1', - } as unknown as SnapshotUploadData); - mockSnapshotUploadService.uploadSnapshot.and.resolveTo({ - accepted: true, - serverSeq: 1, - }); - mockSnapshotUploadService.updateLastServerSeq.and.resolveTo(); - - mockWrappedProviderService = jasmine.createSpyObj('WrappedProviderService', [ - 'clearCache', - ]); - - mockProviderManager = jasmine.createSpyObj('SyncProviderManager', [ - 'getActiveProvider', - 'getEncryptAndCompressCfg', - ]); - mockProviderManager.getActiveProvider.and.returnValue(null); - mockProviderManager.getEncryptAndCompressCfg.and.returnValue({ - isEncrypt: false, - isCompress: true, - }); - - mockStateSnapshotService = jasmine.createSpyObj('StateSnapshotService', [ - 'getStateSnapshotAsync', - ]); - mockStateSnapshotService.getStateSnapshotAsync.and.resolveTo({ task: [] } as any); - - mockVectorClockService = jasmine.createSpyObj('VectorClockService', [ - 'getCurrentVectorClock', - ]); - mockVectorClockService.getCurrentVectorClock.and.resolveTo({ testClient1: 1 }); - - mockFileBasedAdapter = jasmine.createSpyObj('FileBasedSyncAdapterService', [ - 'createAdapter', - ]); - - mockGlobalConfigService = jasmine.createSpyObj('GlobalConfigService', [ - 'updateSection', - ]); - - TestBed.configureTestingModule({ - providers: [ - EncryptionDisableService, - { provide: SnapshotUploadService, useValue: mockSnapshotUploadService }, - { provide: WrappedProviderService, useValue: mockWrappedProviderService }, - { provide: SyncProviderManager, useValue: mockProviderManager }, - { provide: StateSnapshotService, useValue: mockStateSnapshotService }, - { provide: VectorClockService, useValue: mockVectorClockService }, - { - provide: CLIENT_ID_PROVIDER, - useValue: { loadClientId: () => Promise.resolve('testClient1') }, - }, - { provide: FileBasedSyncAdapterService, useValue: mockFileBasedAdapter }, - { provide: GlobalConfigService, useValue: mockGlobalConfigService }, - ], - }); - - service = TestBed.inject(EncryptionDisableService); - }); - - 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 upload unencrypted snapshot with isPayloadEncrypted=false', async () => { - await service.disableEncryption(); - - expect(mockSnapshotUploadService.uploadSnapshot).toHaveBeenCalledWith( - mockSyncProvider as any, - { task: [] }, // Raw state, not encrypted - 'testClient1', - { testClient1: 1 }, - false, // isPayloadEncrypted - ); - }); - - it('should update config with encryption disabled AFTER successful upload', async () => { - await service.disableEncryption(); - - expect(mockSnapshotUploadService.uploadSnapshot).toHaveBeenCalledBefore( - mockSyncProvider.setPrivateCfg, - ); - expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith( - jasmine.objectContaining({ - encryptKey: undefined, - isEncryptionEnabled: false, - }), - ); - }); - - it('should update lastServerSeq after successful upload', async () => { - await service.disableEncryption(); - - expect(mockSnapshotUploadService.updateLastServerSeq).toHaveBeenCalledWith( - mockSyncProvider as any, - 1, - 'EncryptionDisableService', - ); - }); - - it('should NOT update config on upload failure', async () => { - mockSnapshotUploadService.uploadSnapshot.and.rejectWith(new Error('Network error')); - - await expectAsync(service.disableEncryption()).toBeRejected(); - - // Config should NOT be updated when upload fails - expect(mockSyncProvider.setPrivateCfg).not.toHaveBeenCalled(); - }); - - it('should throw with CRITICAL message on upload failure', async () => { - mockSnapshotUploadService.uploadSnapshot.and.rejectWith(new Error('Network error')); - - await expectAsync(service.disableEncryption()).toBeRejectedWithError( - /CRITICAL.*Network error/, - ); - }); - - it('should throw when upload returns not accepted', async () => { - mockSnapshotUploadService.uploadSnapshot.and.resolveTo({ - accepted: false, - error: 'Server rejected', - }); - - await expectAsync(service.disableEncryption()).toBeRejectedWithError( - /CRITICAL.*Server rejected/, - ); - }); - - it('should preserve other config properties when disabling encryption', async () => { - await service.disableEncryption(); - - expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith( - jasmine.objectContaining({ - baseUrl: 'https://test.example.com', - accessToken: 'test-token', - }), - ); - }); - }); - - describe('disableEncryptionForFileBased', () => { - let mockFileBasedProvider: jasmine.SpyObj< - SyncProviderServiceInterface - >; - let mockAdapter: jasmine.SpyObj; - - beforeEach(() => { - mockFileBasedProvider = jasmine.createSpyObj('FileBasedProvider', [ - 'isReady', - 'setPrivateCfg', - ]); - mockFileBasedProvider.id = SyncProviderId.Dropbox; - mockFileBasedProvider.isReady.and.resolveTo(true); - mockFileBasedProvider.setPrivateCfg.and.resolveTo(); - mockFileBasedProvider.privateCfg = { - load: () => Promise.resolve({ encryptKey: 'test-key' }), - } as any; - - mockAdapter = jasmine.createSpyObj('Adapter', [ - 'uploadSnapshot', - 'setLastServerSeq', - ]); - mockAdapter.uploadSnapshot.and.resolveTo({ - accepted: true, - serverSeq: 1, - }); - mockAdapter.setLastServerSeq.and.resolveTo(); - - mockProviderManager.getActiveProvider.and.returnValue(mockFileBasedProvider); - mockFileBasedAdapter.createAdapter.and.returnValue(mockAdapter); - }); - - it('should throw when no active provider', async () => { - mockProviderManager.getActiveProvider.and.returnValue(null); - - await expectAsync(service.disableEncryptionForFileBased()).toBeRejectedWithError( - /No active sync provider/, - ); - }); - - it('should throw when provider is SuperSync (not file-based)', async () => { - const superSyncProvider = jasmine.createSpyObj('SuperSyncProvider', ['isReady']); - superSyncProvider.id = SyncProviderId.SuperSync; - mockProviderManager.getActiveProvider.and.returnValue(superSyncProvider); - - await expectAsync(service.disableEncryptionForFileBased()).toBeRejectedWithError( - /only supported for file-based providers/, - ); - }); - - it('should throw when provider is not ready', async () => { - mockFileBasedProvider.isReady.and.resolveTo(false); - - await expectAsync(service.disableEncryptionForFileBased()).toBeRejectedWithError( - /not ready/, - ); - }); - - it('should create unencrypted adapter and upload snapshot', async () => { - await service.disableEncryptionForFileBased(); - - expect(mockFileBasedAdapter.createAdapter).toHaveBeenCalledWith( - mockFileBasedProvider, - jasmine.objectContaining({ isEncrypt: false }), - undefined, // No encryption key - ); - expect(mockAdapter.uploadSnapshot).toHaveBeenCalled(); - }); - - it('should update config after successful upload', async () => { - await service.disableEncryptionForFileBased(); - - expect(mockFileBasedProvider.setPrivateCfg).toHaveBeenCalledWith( - jasmine.objectContaining({ - encryptKey: undefined, - }), - ); - }); - - it('should NOT update config on upload failure', async () => { - mockAdapter.uploadSnapshot.and.rejectWith(new Error('Network error')); - - await expectAsync(service.disableEncryptionForFileBased()).toBeRejected(); - - expect(mockFileBasedProvider.setPrivateCfg).not.toHaveBeenCalled(); - }); - - it('should clear cache after successful upload', async () => { - await service.disableEncryptionForFileBased(); - - expect(mockWrappedProviderService.clearCache).toHaveBeenCalled(); - }); - - it('should update global config to disable encryption', async () => { - await service.disableEncryptionForFileBased(); - - expect(mockGlobalConfigService.updateSection).toHaveBeenCalledWith('sync', { - isEncryptionEnabled: false, - encryptKey: '', - }); - }); - }); -}); diff --git a/src/app/imex/sync/encryption-enable.service.spec.ts b/src/app/imex/sync/encryption-enable.service.spec.ts deleted file mode 100644 index 8ad088c417..0000000000 --- a/src/app/imex/sync/encryption-enable.service.spec.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { EncryptionEnableService } from './encryption-enable.service'; -import { SnapshotUploadData, SnapshotUploadService } from './snapshot-upload.service'; -import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service'; -import { SyncProviderId } from '../../op-log/sync-providers/provider.const'; -import { - OperationSyncCapable, - SyncProviderServiceInterface, -} from '../../op-log/sync-providers/provider.interface'; -import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model'; -import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service'; -import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service'; - -describe('EncryptionEnableService', () => { - let service: EncryptionEnableService; - let mockSnapshotUploadService: jasmine.SpyObj; - let mockEncryptionService: jasmine.SpyObj; - let mockWrappedProviderService: jasmine.SpyObj; - let mockProviderManager: jasmine.SpyObj; - let mockSyncProvider: jasmine.SpyObj< - SyncProviderServiceInterface & OperationSyncCapable - >; - - const mockExistingCfg: SuperSyncPrivateCfg = { - baseUrl: 'https://test.example.com', - accessToken: 'test-token', - isEncryptionEnabled: false, - encryptKey: undefined, - }; - - beforeEach(() => { - mockSyncProvider = jasmine.createSpyObj('SyncProvider', [ - 'deleteAllData', - 'setPrivateCfg', - ]); - mockSyncProvider.id = SyncProviderId.SuperSync; - mockSyncProvider.deleteAllData.and.resolveTo({ success: true }); - mockSyncProvider.setPrivateCfg.and.resolveTo(); - - mockSnapshotUploadService = jasmine.createSpyObj('SnapshotUploadService', [ - 'gatherSnapshotData', - 'uploadSnapshot', - 'updateLastServerSeq', - ]); - mockSnapshotUploadService.gatherSnapshotData.and.resolveTo({ - syncProvider: mockSyncProvider, - existingCfg: mockExistingCfg, - state: { task: [] }, - vectorClock: { testClient1: 1 }, - clientId: 'testClient1', - } as unknown as SnapshotUploadData); - mockSnapshotUploadService.uploadSnapshot.and.resolveTo({ - accepted: true, - serverSeq: 1, - }); - mockSnapshotUploadService.updateLastServerSeq.and.resolveTo(); - - mockEncryptionService = jasmine.createSpyObj('OperationEncryptionService', [ - 'encryptPayload', - ]); - mockEncryptionService.encryptPayload.and.resolveTo('encrypted-data'); - - mockWrappedProviderService = jasmine.createSpyObj('WrappedProviderService', [ - 'clearCache', - ]); - - mockProviderManager = jasmine.createSpyObj('SyncProviderManager', [ - 'setProviderConfig', - ]); - mockProviderManager.setProviderConfig.and.resolveTo(); - - TestBed.configureTestingModule({ - providers: [ - EncryptionEnableService, - { provide: SnapshotUploadService, useValue: mockSnapshotUploadService }, - { provide: OperationEncryptionService, useValue: mockEncryptionService }, - { provide: WrappedProviderService, useValue: mockWrappedProviderService }, - { provide: SyncProviderManager, useValue: mockProviderManager }, - ], - }); - - service = TestBed.inject(EncryptionEnableService); - }); - - describe('enableEncryption', () => { - it('should throw when encryptKey is empty', async () => { - await expectAsync(service.enableEncryption('')).toBeRejectedWithError( - 'Encryption key is required', - ); - }); - - it('should delete server data before enabling encryption', async () => { - await service.enableEncryption('my-secret-key'); - - expect(mockSyncProvider.deleteAllData).toHaveBeenCalledTimes(1); - expect(mockSyncProvider.deleteAllData).toHaveBeenCalledBefore( - mockProviderManager.setProviderConfig, - ); - }); - - it('should update config with encryption enabled BEFORE upload', 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( - { task: [] }, - '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-data', - 'testClient1', - { testClient1: 1 }, - true, // isPayloadEncrypted - ); - }); - - it('should update lastServerSeq after successful upload', async () => { - await service.enableEncryption('my-secret-key'); - - expect(mockSnapshotUploadService.updateLastServerSeq).toHaveBeenCalledWith( - mockSyncProvider as any, - 1, - 'EncryptionEnableService', - ); - }); - - it('should revert config on upload failure', async () => { - mockSnapshotUploadService.uploadSnapshot.and.rejectWith(new Error('Network error')); - - await expectAsync(service.enableEncryption('my-secret-key')).toBeRejected(); - - // Should have called setProviderConfig twice: - // 1. Enable encryption before upload - // 2. Revert to unencrypted on failure - expect(mockProviderManager.setProviderConfig).toHaveBeenCalledTimes(2); - - // Second call should revert to unencrypted state - const revertCall = mockProviderManager.setProviderConfig.calls.mostRecent(); - expect(revertCall.args[1]).toEqual( - jasmine.objectContaining({ - encryptKey: undefined, - isEncryptionEnabled: false, - }), - ); - }); - - it('should throw with CRITICAL message on upload failure', async () => { - mockSnapshotUploadService.uploadSnapshot.and.rejectWith(new Error('Network error')); - - await expectAsync(service.enableEncryption('my-secret-key')).toBeRejectedWithError( - /CRITICAL.*Network error/, - ); - }); - - it('should revert config when upload returns not accepted', async () => { - mockSnapshotUploadService.uploadSnapshot.and.resolveTo({ - accepted: false, - error: 'Server rejected', - }); - - await expectAsync(service.enableEncryption('my-secret-key')).toBeRejected(); - - // Second call should revert - expect(mockProviderManager.setProviderConfig).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/src/app/imex/sync/encryption-password-change.service.spec.ts b/src/app/imex/sync/encryption-password-change.service.spec.ts index b004ff490e..939852c305 100644 --- a/src/app/imex/sync/encryption-password-change.service.spec.ts +++ b/src/app/imex/sync/encryption-password-change.service.spec.ts @@ -43,6 +43,9 @@ describe('EncryptionPasswordChangeService', () => { getActiveProvider: jasmine .createSpy('getActiveProvider') .and.returnValue(mockSyncProvider), + setProviderConfig: jasmine + .createSpy('setProviderConfig') + .and.returnValue(Promise.resolve()), }; mockCleanSlateService = jasmine.createSpyObj('CleanSlateService', [ @@ -114,10 +117,12 @@ describe('EncryptionPasswordChangeService', () => { // Should create clean slate expect(mockCleanSlateService.createCleanSlate).toHaveBeenCalledWith( 'ENCRYPTION_CHANGE', + 'PASSWORD_CHANGED', ); // Should update config with new password - expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith( + expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith( + SyncProviderId.SuperSync, jasmine.objectContaining({ encryptKey: TEST_PASSWORD, isEncryptionEnabled: true, @@ -144,7 +149,8 @@ describe('EncryptionPasswordChangeService', () => { await service.changePassword(TEST_PASSWORD); - expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith( + expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith( + SyncProviderId.SuperSync, jasmine.objectContaining({ encryptKey: TEST_PASSWORD, isEncryptionEnabled: true, @@ -285,14 +291,15 @@ describe('EncryptionPasswordChangeService', () => { ); // Should have set password to new value - expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith( + expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith( + SyncProviderId.SuperSync, jasmine.objectContaining({ encryptKey: TEST_PASSWORD, }), ); // Should NOT revert - only one call to setPrivateCfg - expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledTimes(1); + expect(mockProviderManager.setProviderConfig).toHaveBeenCalledTimes(1); // Should have cleared cache only once (on change, not on revert) expect(mockDerivedKeyCache.clearCache).toHaveBeenCalledTimes(1); @@ -369,8 +376,8 @@ describe('EncryptionPasswordChangeService', () => { callOrder.push('createCleanSlate'); }); - mockSyncProvider.setPrivateCfg.and.callFake(async () => { - callOrder.push('setPrivateCfg'); + mockProviderManager.setProviderConfig.and.callFake(async () => { + callOrder.push('setProviderConfig'); }); mockDerivedKeyCache.clearCache.and.callFake(() => { @@ -397,7 +404,7 @@ describe('EncryptionPasswordChangeService', () => { 'getUnsynced(1)', // Check for unsynced user ops 'createCleanSlate', 'getUnsynced(2)', // Verify SYNC_IMPORT was stored - 'setPrivateCfg', + 'setProviderConfig', 'clearDerivedKeyCache', 'clearWrappedProviderCache', 'uploadPendingOps', @@ -428,7 +435,8 @@ describe('EncryptionPasswordChangeService', () => { await service.changePassword(TEST_PASSWORD); // Verify BOTH fields are set in the new config - expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith( + expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith( + SyncProviderId.SuperSync, jasmine.objectContaining({ encryptKey: TEST_PASSWORD, isEncryptionEnabled: true, // MUST be true! @@ -452,7 +460,8 @@ describe('EncryptionPasswordChangeService', () => { await service.changePassword(TEST_PASSWORD); // Verify all other fields are preserved - expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith( + expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith( + SyncProviderId.SuperSync, jasmine.objectContaining({ baseUrl: 'https://custom-server.com', accessToken: 'my-access-token', @@ -542,7 +551,7 @@ describe('EncryptionPasswordChangeService', () => { expect(mockCleanSlateService.createCleanSlate).toHaveBeenCalled(); // Should NOT have updated config (failed before reaching that step) - expect(mockSyncProvider.setPrivateCfg).not.toHaveBeenCalled(); + expect(mockProviderManager.setProviderConfig).not.toHaveBeenCalled(); }); it('should generate new client ID via clean slate to prevent operation conflicts', async () => { @@ -560,6 +569,7 @@ describe('EncryptionPasswordChangeService', () => { // Verify clean slate was created (which generates new client ID) expect(mockCleanSlateService.createCleanSlate).toHaveBeenCalledWith( 'ENCRYPTION_CHANGE', + 'PASSWORD_CHANGED', ); }); }); diff --git a/src/app/imex/sync/file-based-encryption.service.spec.ts b/src/app/imex/sync/file-based-encryption.service.spec.ts index 64a0cc552a..4b4cab2020 100644 --- a/src/app/imex/sync/file-based-encryption.service.spec.ts +++ b/src/app/imex/sync/file-based-encryption.service.spec.ts @@ -246,4 +246,68 @@ describe('FileBasedEncryptionService', () => { expect(mockProvider.setPrivateCfg).not.toHaveBeenCalled(); }); }); + + describe('disableEncryption', () => { + it('should create unencrypted adapter and upload snapshot', async () => { + await service.disableEncryption(); + + expect(mockFileBasedAdapter.createAdapter).toHaveBeenCalledWith( + mockProvider, + jasmine.objectContaining({ isEncrypt: false }), + undefined, + ); + expect(mockAdapter.uploadSnapshot).toHaveBeenCalled(); + }); + + it('should use providerManager.setProviderConfig for disable', async () => { + await service.disableEncryption(); + + expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith( + SyncProviderId.WebDAV, + jasmine.objectContaining({ + encryptKey: undefined, + }), + ); + + // Should NOT call setPrivateCfg directly + expect(mockProvider.setPrivateCfg).not.toHaveBeenCalled(); + }); + + it('should update global config to disable encryption', async () => { + await service.disableEncryption(); + + expect(mockGlobalConfigService.updateSection).toHaveBeenCalledWith('sync', { + isEncryptionEnabled: false, + encryptKey: '', + }); + }); + + it('should clear caches after successful upload', async () => { + await service.disableEncryption(); + + expect(mockDerivedKeyCache.clearCache).toHaveBeenCalled(); + expect(mockWrappedProviderService.clearCache).toHaveBeenCalled(); + }); + + it('should NOT update config on upload failure', async () => { + mockAdapter.uploadSnapshot.and.resolveTo({ + accepted: false, + error: 'Upload rejected', + }); + + await expectAsync(service.disableEncryption()).toBeRejectedWithError( + /Snapshot upload failed/, + ); + + expect(mockProviderManager.setProviderConfig).not.toHaveBeenCalled(); + }); + + it('should throw when no active provider', async () => { + mockProviderManager.getActiveProvider.and.returnValue(null); + + await expectAsync(service.disableEncryption()).toBeRejectedWithError( + /No active sync provider/, + ); + }); + }); }); diff --git a/src/app/imex/sync/supersync-encryption-toggle.service.spec.ts b/src/app/imex/sync/supersync-encryption-toggle.service.spec.ts new file mode 100644 index 0000000000..8d9fac38ae --- /dev/null +++ b/src/app/imex/sync/supersync-encryption-toggle.service.spec.ts @@ -0,0 +1,479 @@ +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 { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service'; +import { SyncProviderId } from '../../op-log/sync-providers/provider.const'; +import { + OperationSyncCapable, + SyncProviderServiceInterface, +} from '../../op-log/sync-providers/provider.interface'; +import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model'; + +describe('SuperSyncEncryptionToggleService', () => { + let service: SuperSyncEncryptionToggleService; + let mockSnapshotUploadService: jasmine.SpyObj; + let mockEncryptionService: jasmine.SpyObj; + let mockWrappedProviderService: jasmine.SpyObj; + let mockProviderManager: jasmine.SpyObj; + let mockSyncProvider: jasmine.SpyObj< + SyncProviderServiceInterface & OperationSyncCapable + >; + + const mockExistingCfg: SuperSyncPrivateCfg = { + baseUrl: 'https://test.example.com', + accessToken: 'test-token', + isEncryptionEnabled: false, + encryptKey: undefined, + }; + + const mockState = { task: [], project: [] }; + const mockVectorClock = { testClient1: 1 }; + const mockClientId = 'testClient1'; + + beforeEach(() => { + mockSyncProvider = jasmine.createSpyObj('SyncProvider', [ + 'deleteAllData', + 'setPrivateCfg', + ]); + mockSyncProvider.id = SyncProviderId.SuperSync; + mockSyncProvider.deleteAllData.and.resolveTo({ success: true }); + mockSyncProvider.setPrivateCfg.and.resolveTo(); + mockSyncProvider.privateCfg = { + load: jasmine.createSpy('load').and.resolveTo(mockExistingCfg), + } as any; + (mockSyncProvider as any).supportsOperationSync = true; + + mockProviderManager = jasmine.createSpyObj('SyncProviderManager', [ + 'getActiveProvider', + 'setProviderConfig', + ]); + mockProviderManager.getActiveProvider.and.returnValue(mockSyncProvider as any); + mockProviderManager.setProviderConfig.and.resolveTo(); + + mockSnapshotUploadService = jasmine.createSpyObj('SnapshotUploadService', [ + 'gatherSnapshotData', + 'uploadSnapshot', + 'updateLastServerSeq', + ]); + mockSnapshotUploadService.gatherSnapshotData.and.resolveTo({ + syncProvider: mockSyncProvider, + existingCfg: mockExistingCfg, + state: mockState, + vectorClock: mockVectorClock, + clientId: mockClientId, + } as unknown as SnapshotUploadData); + mockSnapshotUploadService.uploadSnapshot.and.resolveTo({ + accepted: true, + serverSeq: 42, + }); + 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 }, + ], + }); + + service = TestBed.inject(SuperSyncEncryptionToggleService); + }); + + describe('enableEncryption', () => { + it('should throw when encryptKey is empty', async () => { + await expectAsync(service.enableEncryption('')).toBeRejectedWithError( + 'Encryption key is required', + ); + }); + + it('should skip when encryption is already enabled', async () => { + (mockSyncProvider.privateCfg.load as jasmine.Spy).and.resolveTo({ + ...mockExistingCfg, + isEncryptionEnabled: true, + encryptKey: 'existing-key', + }); + + await service.enableEncryption('new-key'); + + expect(mockSnapshotUploadService.gatherSnapshotData).not.toHaveBeenCalled(); + }); + + it('should delete server data before enabling encryption', 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', + }); + + 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) + expect(mockProviderManager.setProviderConfig).toHaveBeenCalledWith( + SyncProviderId.SuperSync, + jasmine.objectContaining({ + 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', + }); + + 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')); + + 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, + encryptKey: undefined, + }; + mockSnapshotUploadService.gatherSnapshotData.and.resolveTo({ + syncProvider: mockSyncProvider, + existingCfg, + state: mockState, + vectorClock: mockVectorClock, + clientId: mockClientId, + } as unknown as SnapshotUploadData); + + await service.enableEncryption('my-secret-key'); + + 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 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')); + + 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', + ]); + }); + }); +}); diff --git a/src/app/imex/sync/sync-config.service.spec.ts b/src/app/imex/sync/sync-config.service.spec.ts index 1e1d6a910b..0b8e31d826 100644 --- a/src/app/imex/sync/sync-config.service.spec.ts +++ b/src/app/imex/sync/sync-config.service.spec.ts @@ -8,15 +8,18 @@ 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', () => { let service: SyncConfigService; let providerManager: jasmine.SpyObj; let mockSyncConfig$: BehaviorSubject; let mockCurrentProviderPrivateCfg$: BehaviorSubject; + let originalFetch: typeof globalThis.fetch; beforeEach(() => { // Mock fetch for the sync-config-default-override.json + originalFetch = globalThis.fetch; // @ts-ignore - fetch might not exist in test environment globalThis.fetch = jasmine.createSpy('fetch').and.returnValue( Promise.resolve({ @@ -54,12 +57,17 @@ describe('SyncConfigService', () => { 'clearCache', ]); + const syncWrapperServiceSpy = jasmine.createSpyObj('SyncWrapperService', [ + 'clearEncryptionDialogSuppression', + ]); + TestBed.configureTestingModule({ providers: [ SyncConfigService, { provide: SyncProviderManager, useValue: providerManagerSpy }, { provide: GlobalConfigService, useValue: globalConfigServiceSpy }, { provide: WrappedProviderService, useValue: wrappedProviderServiceSpy }, + { provide: SyncWrapperService, useValue: syncWrapperServiceSpy }, ], }); @@ -69,6 +77,10 @@ describe('SyncConfigService', () => { ) as jasmine.SpyObj; }); + afterEach(() => { + globalThis.fetch = originalFetch; + }); + describe('updateSettingsFromForm', () => { it('should update global config with non-private data only', async () => { const globalConfigService = TestBed.inject( @@ -579,6 +591,37 @@ describe('SyncConfigService', () => { expect(providerManager.setProviderConfig).toHaveBeenCalledTimes(2); }); + it('should deduplicate when syncSettingsForm$ emits before Formly modelChange', async () => { + // Mock provider for the test + (providerManager.getProviderById as jasmine.Spy).and.returnValue({ + id: SyncProviderId.WebDAV, + privateCfg: { + load: jasmine.createSpy('load').and.returnValue(Promise.resolve({})), + }, + }); + + // Simulate syncSettingsForm$ emission by pushing provider config + mockCurrentProviderPrivateCfg$.next({ + providerId: SyncProviderId.WebDAV, + privateCfg: { + baseUrl: 'https://example.com', + userName: 'user', + password: 'pass', + syncFolderPath: '/sync', + encryptKey: 'key', + }, + }); + + // Capture the actual emitted value from syncSettingsForm$ + const emittedSettings = await service.syncSettingsForm$.pipe(first()).toPromise(); + + // Now simulate Formly modelChange with the exact same emitted value + await service.updateSettingsFromForm(emittedSettings!); + + // Should NOT have called setProviderConfig since _lastSettings matches + expect(providerManager.setProviderConfig).not.toHaveBeenCalled(); + }); + it('should not save private config when no provider is selected', async () => { const settings: SyncConfig = { isEnabled: false, @@ -1038,6 +1081,39 @@ describe('SyncConfigService', () => { expect(callArgs.isEncryptionEnabled).toBeUndefined(); }); + it('should not dispatch persistent global config action when updating password', async () => { + const globalConfigService = TestBed.inject( + GlobalConfigService, + ) as jasmine.SpyObj; + globalConfigService.updateSection.calls.reset(); + + const mockProvider = { + id: SyncProviderId.SuperSync, + privateCfg: { + load: jasmine.createSpy('load').and.returnValue( + Promise.resolve({ + baseUrl: 'http://test.com', + userName: 'test', + password: 'test', + accessToken: 'token', + syncFolderPath: '/', + encryptKey: 'oldpass', + isEncryptionEnabled: false, + }), + ), + }, + }; + (providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider); + (providerManager.getActiveProvider as jasmine.Spy).and.returnValue(mockProvider); + + await service.updateEncryptionPassword('newpass', SyncProviderId.SuperSync); + + // Must NOT dispatch a persistent global config update - this caused the + // encryption password change cascade bug where other clients couldn't + // decrypt the operation and got stuck in a decrypt error loop + expect(globalConfigService.updateSection).not.toHaveBeenCalled(); + }); + it('should preserve existing config when updating password', async () => { // Setup SuperSync provider with existing config const existingConfig = { diff --git a/src/app/imex/sync/sync-wrapper.service.spec.ts b/src/app/imex/sync/sync-wrapper.service.spec.ts index 656172ff9f..8b69c3f353 100644 --- a/src/app/imex/sync/sync-wrapper.service.spec.ts +++ b/src/app/imex/sync/sync-wrapper.service.spec.ts @@ -129,7 +129,9 @@ describe('SyncWrapperService', () => { }); mockSnackService = jasmine.createSpyObj('SnackService', ['open']); - mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']); + mockMatDialog = jasmine.createSpyObj('MatDialog', ['open'], { + openDialogs: [], + }); mockTranslateService = jasmine.createSpyObj('TranslateService', ['instant']); mockTranslateService.instant.and.callFake((key: string) => key); diff --git a/src/app/op-log/clean-slate/clean-slate.service.spec.ts b/src/app/op-log/clean-slate/clean-slate.service.spec.ts index 17283e19f1..5ef3e3d892 100644 --- a/src/app/op-log/clean-slate/clean-slate.service.spec.ts +++ b/src/app/op-log/clean-slate/clean-slate.service.spec.ts @@ -66,7 +66,7 @@ describe('CleanSlateService', () => { describe('createCleanSlate', () => { it('should create a clean slate successfully', async () => { - await service.createCleanSlate('ENCRYPTION_CHANGE'); + await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'); // Should create pre-migration backup expect(mockPreMigrationBackupService.createPreMigrationBackup).toHaveBeenCalledWith( @@ -107,7 +107,7 @@ describe('CleanSlateService', () => { }); it('should work with MANUAL reason', async () => { - await service.createCleanSlate('MANUAL'); + await service.createCleanSlate('MANUAL', 'PASSWORD_CHANGED'); expect(mockPreMigrationBackupService.createPreMigrationBackup).toHaveBeenCalledWith( 'MANUAL', @@ -120,7 +120,9 @@ describe('CleanSlateService', () => { ); // Should not throw - backup failure is non-fatal - await expectAsync(service.createCleanSlate('ENCRYPTION_CHANGE')).toBeResolved(); + await expectAsync( + service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), + ).toBeResolved(); // Should still complete the clean slate expect(mockOpLogStore.clearAllOperations).toHaveBeenCalled(); @@ -128,14 +130,14 @@ describe('CleanSlateService', () => { }); it('should generate fresh vector clock starting at 1', async () => { - await service.createCleanSlate('ENCRYPTION_CHANGE'); + await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'); const appendedOp = mockOpLogStore.append.calls.mostRecent().args[0] as Operation; expect(appendedOp.vectorClock).toEqual({ eNewC: 1 }); }); it('should create operation with valid UUIDv7', async () => { - await service.createCleanSlate('ENCRYPTION_CHANGE'); + await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'); const appendedOp = mockOpLogStore.append.calls.mostRecent().args[0] as Operation; // UUIDv7 format: 8-4-4-4-12 characters @@ -149,25 +151,25 @@ describe('CleanSlateService', () => { new Error('State error'), ); - await expectAsync(service.createCleanSlate('ENCRYPTION_CHANGE')).toBeRejectedWith( - jasmine.objectContaining({ message: 'State error' }), - ); + await expectAsync( + service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), + ).toBeRejectedWith(jasmine.objectContaining({ message: 'State error' })); }); it('should throw if client ID generation fails', async () => { mockClientIdService.generateNewClientId.and.rejectWith(new Error('ClientID error')); - await expectAsync(service.createCleanSlate('ENCRYPTION_CHANGE')).toBeRejectedWith( - jasmine.objectContaining({ message: 'ClientID error' }), - ); + await expectAsync( + service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), + ).toBeRejectedWith(jasmine.objectContaining({ message: 'ClientID error' })); }); it('should throw if operation append fails', async () => { mockOpLogStore.append.and.rejectWith(new Error('Append error')); - await expectAsync(service.createCleanSlate('ENCRYPTION_CHANGE')).toBeRejectedWith( - jasmine.objectContaining({ message: 'Append error' }), - ); + await expectAsync( + service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), + ).toBeRejectedWith(jasmine.objectContaining({ message: 'Append error' })); }); }); @@ -175,25 +177,25 @@ describe('CleanSlateService', () => { it('should propagate clearAllOperations errors', async () => { mockOpLogStore.clearAllOperations.and.rejectWith(new Error('Clear failed')); - await expectAsync(service.createCleanSlate('ENCRYPTION_CHANGE')).toBeRejectedWith( - jasmine.objectContaining({ message: 'Clear failed' }), - ); + await expectAsync( + service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), + ).toBeRejectedWith(jasmine.objectContaining({ message: 'Clear failed' })); }); it('should propagate setVectorClock errors', async () => { mockOpLogStore.setVectorClock.and.rejectWith(new Error('VectorClock failed')); - await expectAsync(service.createCleanSlate('ENCRYPTION_CHANGE')).toBeRejectedWith( - jasmine.objectContaining({ message: 'VectorClock failed' }), - ); + await expectAsync( + service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), + ).toBeRejectedWith(jasmine.objectContaining({ message: 'VectorClock failed' })); }); it('should propagate saveStateCache errors', async () => { mockOpLogStore.saveStateCache.and.rejectWith(new Error('SaveCache failed')); - await expectAsync(service.createCleanSlate('ENCRYPTION_CHANGE')).toBeRejectedWith( - jasmine.objectContaining({ message: 'SaveCache failed' }), - ); + await expectAsync( + service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'), + ).toBeRejectedWith(jasmine.objectContaining({ message: 'SaveCache failed' })); }); }); @@ -208,7 +210,7 @@ describe('CleanSlateService', () => { return 1; }); - await service.createCleanSlate('ENCRYPTION_CHANGE'); + await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'); expect(callOrder).toEqual(['clear', 'append']); }); @@ -223,7 +225,7 @@ describe('CleanSlateService', () => { callOrder.push('setVectorClock'); }); - await service.createCleanSlate('ENCRYPTION_CHANGE'); + await service.createCleanSlate('ENCRYPTION_CHANGE', 'PASSWORD_CHANGED'); expect(callOrder).toEqual(['append', 'setVectorClock']); }); diff --git a/src/app/op-log/sync/operation-log-sync.service.spec.ts b/src/app/op-log/sync/operation-log-sync.service.spec.ts index 7c660675a6..d9e58697b8 100644 --- a/src/app/op-log/sync/operation-log-sync.service.spec.ts +++ b/src/app/op-log/sync/operation-log-sync.service.spec.ts @@ -29,6 +29,7 @@ import { import { TranslateService } from '@ngx-translate/core'; import { LocalDataConflictError } from '../core/errors/sync-errors'; import { SyncHydrationService } from '../persistence/sync-hydration.service'; +import { SyncImportConflictDialogService } from './sync-import-conflict-dialog.service'; import { StateSnapshotService } from '../backup/state-snapshot.service'; import { INBOX_PROJECT } from '../../features/project/project.const'; import { TODAY_TAG, SYSTEM_TAG_IDS } from '../../features/tag/tag.const'; @@ -43,6 +44,7 @@ describe('OperationLogSyncService', () => { let writeFlushServiceSpy: jasmine.SpyObj; let superSyncStatusServiceSpy: jasmine.SpyObj; let stateSnapshotServiceSpy: jasmine.SpyObj; + let syncImportConflictDialogServiceSpy: jasmine.SpyObj; beforeEach(() => { snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']); @@ -102,6 +104,12 @@ describe('OperationLogSyncService', () => { 'updatePendingOpsStatus', ]); + syncImportConflictDialogServiceSpy = jasmine.createSpyObj( + 'SyncImportConflictDialogService', + ['showConflictDialog'], + ); + syncImportConflictDialogServiceSpy.showConflictDialog.and.resolveTo('CANCEL'); + TestBed.configureTestingModule({ providers: [ OperationLogSyncService, @@ -196,6 +204,10 @@ describe('OperationLogSyncService', () => { ]), }, { provide: StateSnapshotService, useValue: stateSnapshotServiceSpy }, + { + provide: SyncImportConflictDialogService, + useValue: syncImportConflictDialogServiceSpy, + }, ], }); @@ -430,7 +442,7 @@ describe('OperationLogSyncService', () => { expect(result?.localWinOpsCreated).toBe(5); }); - it('should call handleRejectedOps in finally block even if processRemoteOps throws', async () => { + it('should not call handleRejectedOps if processRemoteOps throws', async () => { const piggybackedOp: Operation = { id: 'piggybacked-1', clientId: 'client-B', @@ -462,8 +474,8 @@ describe('OperationLogSyncService', () => { 'Processing failed', ); - // handleRejectedOps should still be called (via finally block) - expect(rejectedOpsHandlerServiceSpy.handleRejectedOps).toHaveBeenCalled(); + // handleRejectedOps should NOT be called — error propagates before reaching rejection handling + expect(rejectedOpsHandlerServiceSpy.handleRejectedOps).not.toHaveBeenCalled(); }); it('should not call handleRejectedOps when there are no rejected ops', async () => { @@ -1040,7 +1052,7 @@ describe('OperationLogSyncService', () => { expect(serverMigrationServiceSpy.handleServerMigration).toHaveBeenCalledWith( mockProvider, - { skipServerEmptyCheck: true }, // Force upload skips the server empty check + { skipServerEmptyCheck: true, syncImportReason: 'FORCE_UPLOAD' }, ); }); @@ -1627,4 +1639,430 @@ describe('OperationLogSyncService', () => { } }); }); + + describe('pre-op-log client on empty server (I.2 fix)', () => { + let downloadServiceSpy: jasmine.SpyObj; + + beforeEach(() => { + downloadServiceSpy = TestBed.inject( + OperationLogDownloadService, + ) as jasmine.SpyObj; + + // Make this a fresh client (no snapshot, no ops) + opLogStoreSpy.loadStateCache.and.resolveTo(null); + opLogStoreSpy.getLastSeq.and.resolveTo(0); + opLogStoreSpy.getUnsynced.and.resolveTo([]); + }); + + it('should create SYNC_IMPORT via migration service when fresh client has meaningful data on empty server', async () => { + // Store has tasks (meaningful data) + stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({ + task: { ids: ['task-1'] }, + project: { ids: [INBOX_PROJECT.id] }, + tag: { ids: [TODAY_TAG.id] }, + note: { ids: [] }, + } as any); + + downloadServiceSpy.downloadRemoteOps.and.resolveTo({ + newOps: [], + needsFullStateUpload: false, + success: true, + failedFileCount: 0, + latestServerSeq: 0, // Empty server + }); + + const mockProvider = { + supportsOperationSync: true, + setLastServerSeq: jasmine.createSpy('setLastServerSeq').and.resolveTo(), + } as any; + + const result = await service.downloadRemoteOps(mockProvider); + + expect(serverMigrationServiceSpy.handleServerMigration).toHaveBeenCalledWith( + mockProvider, + { syncImportReason: 'SERVER_MIGRATION' }, + ); + expect(result.serverMigrationHandled).toBe(true); + }); + + it('should NOT create SYNC_IMPORT when fresh client has no meaningful data on empty server', async () => { + // Store has only default data + stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({ + task: { ids: [] }, + project: { ids: [INBOX_PROJECT.id] }, + tag: { ids: [TODAY_TAG.id] }, + note: { ids: [] }, + } as any); + + downloadServiceSpy.downloadRemoteOps.and.resolveTo({ + newOps: [], + needsFullStateUpload: false, + success: true, + failedFileCount: 0, + latestServerSeq: 0, // Empty server + }); + + const mockProvider = { + supportsOperationSync: true, + setLastServerSeq: jasmine.createSpy('setLastServerSeq').and.resolveTo(), + } as any; + + const result = await service.downloadRemoteOps(mockProvider); + + expect(serverMigrationServiceSpy.handleServerMigration).not.toHaveBeenCalled(); + expect(result.serverMigrationHandled).toBe(false); + }); + + it('should NOT create SYNC_IMPORT when server is not empty (latestServerSeq > 0)', async () => { + // Store has tasks + stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({ + task: { ids: ['task-1'] }, + project: { ids: [INBOX_PROJECT.id] }, + tag: { ids: [TODAY_TAG.id] }, + note: { ids: [] }, + } as any); + + downloadServiceSpy.downloadRemoteOps.and.resolveTo({ + newOps: [], + needsFullStateUpload: false, + success: true, + failedFileCount: 0, + latestServerSeq: 5, // Server has data (just no new ops for us) + }); + + const mockProvider = { + supportsOperationSync: true, + setLastServerSeq: jasmine.createSpy('setLastServerSeq').and.resolveTo(), + } as any; + + const result = await service.downloadRemoteOps(mockProvider); + + expect(serverMigrationServiceSpy.handleServerMigration).not.toHaveBeenCalled(); + expect(result.serverMigrationHandled).toBe(false); + }); + + it('should NOT create SYNC_IMPORT when client is not fresh (has op-log history)', async () => { + // Client has history (not fresh) + opLogStoreSpy.loadStateCache.and.resolveTo({ + state: {}, + lastAppliedOpSeq: 5, + vectorClock: { clientA: 5 }, + compactedAt: Date.now(), + }); + opLogStoreSpy.getLastSeq.and.resolveTo(5); + + // Store has tasks + stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({ + task: { ids: ['task-1'] }, + project: { ids: [INBOX_PROJECT.id] }, + tag: { ids: [TODAY_TAG.id] }, + note: { ids: [] }, + } as any); + + downloadServiceSpy.downloadRemoteOps.and.resolveTo({ + newOps: [], + needsFullStateUpload: false, + success: true, + failedFileCount: 0, + latestServerSeq: 0, + }); + + const mockProvider = { + supportsOperationSync: true, + setLastServerSeq: jasmine.createSpy('setLastServerSeq').and.resolveTo(), + } as any; + + const result = await service.downloadRemoteOps(mockProvider); + + // Should NOT call handleServerMigration - client is not fresh + expect(serverMigrationServiceSpy.handleServerMigration).not.toHaveBeenCalled(); + expect(result.serverMigrationHandled).toBe(false); + }); + }); + + describe('piggybacked SYNC_IMPORT conflict dialog', () => { + let uploadServiceSpy: jasmine.SpyObj; + + beforeEach(() => { + uploadServiceSpy = TestBed.inject( + OperationLogUploadService, + ) as jasmine.SpyObj; + + // Not a fresh client + opLogStoreSpy.loadStateCache.and.resolveTo({ + state: {}, + lastAppliedOpSeq: 1, + vectorClock: {}, + compactedAt: Date.now(), + }); + opLogStoreSpy.getLastSeq.and.resolveTo(1); + }); + + it('should show conflict dialog when piggybacked ops contain SYNC_IMPORT and client has pending ops', async () => { + const piggybackedSyncImport: Operation = { + id: 'import-1', + clientId: 'client-B', + actionType: ActionType.LOAD_ALL_DATA, + opType: OpType.SyncImport, + entityType: 'ALL', + payload: { task: { ids: ['remote-task'] } }, + vectorClock: { clientB: 5 }, + timestamp: Date.now(), + schemaVersion: 1, + syncImportReason: 'SERVER_MIGRATION', + }; + + uploadServiceSpy.uploadPendingOps.and.resolveTo({ + uploadedCount: 1, + piggybackedOps: [piggybackedSyncImport], + rejectedCount: 0, + rejectedOps: [], + }); + + // Client has pending ops + const pendingEntry: OperationLogEntry = { + seq: 1, + op: { + id: 'local-op-1', + clientId: 'client-A', + actionType: 'test' as ActionType, + opType: OpType.Update, + entityType: 'TASK', + entityId: 'task-1', + payload: { title: 'Local Title' }, + vectorClock: { clientA: 1 }, + timestamp: Date.now(), + schemaVersion: 1, + }, + appliedAt: Date.now(), + source: 'local', + }; + opLogStoreSpy.getUnsynced.and.resolveTo([pendingEntry]); + + syncImportConflictDialogServiceSpy.showConflictDialog.and.resolveTo('CANCEL'); + + const mockProvider = { + isReady: () => Promise.resolve(true), + } as any; + + const result = await service.uploadPendingOps(mockProvider); + + expect(syncImportConflictDialogServiceSpy.showConflictDialog).toHaveBeenCalledWith( + jasmine.objectContaining({ + scenario: 'INCOMING_IMPORT', + syncImportReason: 'SERVER_MIGRATION', + }), + ); + expect(result?.cancelled).toBe(true); + }); + + it('should show conflict dialog when piggybacked ops contain SYNC_IMPORT and client has meaningful data', async () => { + const piggybackedSyncImport: Operation = { + id: 'import-1', + clientId: 'client-B', + actionType: ActionType.LOAD_ALL_DATA, + opType: OpType.SyncImport, + entityType: 'ALL', + payload: {}, + vectorClock: { clientB: 5 }, + timestamp: Date.now(), + schemaVersion: 1, + }; + + uploadServiceSpy.uploadPendingOps.and.resolveTo({ + uploadedCount: 1, + piggybackedOps: [piggybackedSyncImport], + rejectedCount: 0, + rejectedOps: [], + }); + + // No pending ops but meaningful local data + opLogStoreSpy.getUnsynced.and.resolveTo([]); + stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({ + task: { ids: ['task-1'] }, + project: { ids: [INBOX_PROJECT.id] }, + tag: { ids: [TODAY_TAG.id] }, + note: { ids: [] }, + } as any); + + syncImportConflictDialogServiceSpy.showConflictDialog.and.resolveTo('CANCEL'); + + const mockProvider = { + isReady: () => Promise.resolve(true), + } as any; + + const result = await service.uploadPendingOps(mockProvider); + + expect(syncImportConflictDialogServiceSpy.showConflictDialog).toHaveBeenCalled(); + expect(result?.cancelled).toBe(true); + }); + + it('should process piggybacked SYNC_IMPORT silently when no meaningful local data', async () => { + const piggybackedSyncImport: Operation = { + id: 'import-1', + clientId: 'client-B', + actionType: ActionType.LOAD_ALL_DATA, + opType: OpType.SyncImport, + entityType: 'ALL', + payload: {}, + vectorClock: { clientB: 5 }, + timestamp: Date.now(), + schemaVersion: 1, + }; + + uploadServiceSpy.uploadPendingOps.and.resolveTo({ + uploadedCount: 1, + piggybackedOps: [piggybackedSyncImport], + rejectedCount: 0, + rejectedOps: [], + }); + + // No pending ops AND no meaningful data (only defaults) + opLogStoreSpy.getUnsynced.and.resolveTo([]); + stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({ + task: { ids: [] }, + project: { ids: [INBOX_PROJECT.id] }, + tag: { ids: [TODAY_TAG.id] }, + note: { ids: [] }, + } as any); + + const mockProvider = { + isReady: () => Promise.resolve(true), + } as any; + + const result = await service.uploadPendingOps(mockProvider); + + // Should NOT show dialog + expect( + syncImportConflictDialogServiceSpy.showConflictDialog, + ).not.toHaveBeenCalled(); + // Should process normally via processRemoteOps + expect(remoteOpsProcessingServiceSpy.processRemoteOps).toHaveBeenCalledWith([ + piggybackedSyncImport, + ]); + expect(result?.cancelled).toBeUndefined(); + }); + + it('should process piggybacked ops normally when no SYNC_IMPORT present', async () => { + const piggybackedOp: Operation = { + id: 'op-1', + clientId: 'client-B', + actionType: 'test' as ActionType, + opType: OpType.Update, + entityType: 'TASK', + entityId: 'task-1', + payload: { title: 'Remote Title' }, + vectorClock: { clientB: 1 }, + timestamp: Date.now(), + schemaVersion: 1, + }; + + uploadServiceSpy.uploadPendingOps.and.resolveTo({ + uploadedCount: 1, + piggybackedOps: [piggybackedOp], + rejectedCount: 0, + rejectedOps: [], + }); + + opLogStoreSpy.getUnsynced.and.resolveTo([]); + + const mockProvider = { + isReady: () => Promise.resolve(true), + } as any; + + const result = await service.uploadPendingOps(mockProvider); + + // Should NOT show dialog + expect( + syncImportConflictDialogServiceSpy.showConflictDialog, + ).not.toHaveBeenCalled(); + // Should process normally + expect(remoteOpsProcessingServiceSpy.processRemoteOps).toHaveBeenCalledWith([ + piggybackedOp, + ]); + expect(result?.cancelled).toBeUndefined(); + }); + + it('should call forceUploadLocalState when user chooses USE_LOCAL', async () => { + const piggybackedSyncImport: Operation = { + id: 'import-1', + clientId: 'client-B', + actionType: ActionType.LOAD_ALL_DATA, + opType: OpType.SyncImport, + entityType: 'ALL', + payload: {}, + vectorClock: { clientB: 5 }, + timestamp: Date.now(), + schemaVersion: 1, + }; + + uploadServiceSpy.uploadPendingOps.and.resolveTo({ + uploadedCount: 1, + piggybackedOps: [piggybackedSyncImport], + rejectedCount: 0, + rejectedOps: [], + }); + + opLogStoreSpy.getUnsynced.and.resolveTo([]); + stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({ + task: { ids: ['task-1'] }, + project: { ids: [INBOX_PROJECT.id] }, + tag: { ids: [TODAY_TAG.id] }, + note: { ids: [] }, + } as any); + + syncImportConflictDialogServiceSpy.showConflictDialog.and.resolveTo('USE_LOCAL'); + + const forceUploadSpy = spyOn(service, 'forceUploadLocalState').and.resolveTo(); + + const mockProvider = { + isReady: () => Promise.resolve(true), + } as any; + + await service.uploadPendingOps(mockProvider); + + expect(forceUploadSpy).toHaveBeenCalledWith(mockProvider); + }); + + it('should call forceDownloadRemoteState when user chooses USE_REMOTE', async () => { + const piggybackedSyncImport: Operation = { + id: 'import-1', + clientId: 'client-B', + actionType: ActionType.LOAD_ALL_DATA, + opType: OpType.SyncImport, + entityType: 'ALL', + payload: {}, + vectorClock: { clientB: 5 }, + timestamp: Date.now(), + schemaVersion: 1, + }; + + uploadServiceSpy.uploadPendingOps.and.resolveTo({ + uploadedCount: 1, + piggybackedOps: [piggybackedSyncImport], + rejectedCount: 0, + rejectedOps: [], + }); + + opLogStoreSpy.getUnsynced.and.resolveTo([]); + stateSnapshotServiceSpy.getStateSnapshot.and.returnValue({ + task: { ids: ['task-1'] }, + project: { ids: [INBOX_PROJECT.id] }, + tag: { ids: [TODAY_TAG.id] }, + note: { ids: [] }, + } as any); + + syncImportConflictDialogServiceSpy.showConflictDialog.and.resolveTo('USE_REMOTE'); + + const forceDownloadSpy = spyOn(service, 'forceDownloadRemoteState').and.resolveTo(); + + const mockProvider = { + isReady: () => Promise.resolve(true), + } as any; + + await service.uploadPendingOps(mockProvider); + + expect(forceDownloadSpy).toHaveBeenCalledWith(mockProvider); + }); + }); }); diff --git a/src/app/op-log/sync/sync-import-filter.service.spec.ts b/src/app/op-log/sync/sync-import-filter.service.spec.ts index 8a69ec4306..c7b90aa511 100644 --- a/src/app/op-log/sync/sync-import-filter.service.spec.ts +++ b/src/app/op-log/sync/sync-import-filter.service.spec.ts @@ -1184,6 +1184,8 @@ describe('SyncImportFilterService', () => { }); it('should set isLocalUnsyncedImport=false when stored import is local but already synced', async () => { + // Once synced, the import is established — old straggler ops should be + // silently discarded without showing the conflict dialog. const storedImportOp = createOp({ id: '019afd68-0050-7000-0000-000000000000', opType: OpType.SyncImport,