diff --git a/e2e/pages/supersync.page.ts b/e2e/pages/supersync.page.ts index f2f757c407..2b217f2e59 100644 --- a/e2e/pages/supersync.page.ts +++ b/e2e/pages/supersync.page.ts @@ -672,4 +672,105 @@ export class SuperSyncPage extends BasePage { await dialogContainer.waitFor({ state: 'detached', timeout: 5000 }); } } + + /** + * Open the encryption/sync settings dialog. + * This opens the main sync configuration dialog where encryption can be managed. + */ + async openEncryptionDialog(): Promise { + // Open sync settings via right-click on sync button + await this.syncBtn.click({ button: 'right' }); + await this.providerSelect.waitFor({ state: 'visible', timeout: 10000 }); + + // Expand "Advanced settings" collapsible to access encryption fields + const advancedCollapsible = this.page.locator( + '.collapsible-header:has-text("Advanced")', + ); + await advancedCollapsible.waitFor({ state: 'visible', timeout: 5000 }); + + // Check if already expanded + const isExpanded = await this.encryptionCheckbox.isVisible().catch(() => false); + if (!isExpanded) { + await advancedCollapsible.click(); + await this.encryptionCheckbox.waitFor({ state: 'visible', timeout: 3000 }); + } + } + + /** + * Enable encryption by reconfiguring the SuperSync provider. + * This will trigger a clean slate operation (server wipe + fresh encrypted upload). + * + * Prerequisites: SuperSync must already be configured (call setupSuperSync first) + * + * @param password - The encryption password to use + */ + async enableEncryption(password: string): Promise { + // Enable encryption + const checkboxLabel = this.page.locator('.e2e-isEncryptionEnabled label'); + await this.encryptionCheckbox.waitFor({ state: 'attached', timeout: 5000 }); + await this.page.waitForTimeout(200); + + // Check if already enabled + const isChecked = await this.encryptionCheckbox.isChecked(); + if (!isChecked) { + await checkboxLabel.click(); + await this.page.waitForTimeout(100); + await expect(this.encryptionCheckbox).toBeChecked({ timeout: 3000 }); + } + + // Fill in password + await this.encryptionPasswordInput.waitFor({ state: 'visible' }); + await this.encryptionPasswordInput.fill(password); + await this.encryptionPasswordInput.blur(); + await this.page.waitForTimeout(200); + + // Save the configuration + await this.saveBtn.waitFor({ state: 'visible', timeout: 5000 }); + await this.page.waitForTimeout(100); + + await Promise.race([ + this.saveBtn.click({ timeout: 5000 }), + this.page + .locator('mat-dialog-container') + .waitFor({ state: 'detached', timeout: 5000 }), + ]); + + // Wait for clean slate operation to complete + await this.page.waitForTimeout(1000); + } + + /** + * Disable encryption by reconfiguring the SuperSync provider. + * This will trigger a clean slate operation (server wipe + fresh unencrypted upload). + * + * Prerequisites: SuperSync must already be configured with encryption enabled + */ + async disableEncryption(): Promise { + // Uncheck encryption + const checkboxLabel = this.page.locator('.e2e-isEncryptionEnabled label'); + await this.encryptionCheckbox.waitFor({ state: 'attached', timeout: 5000 }); + await this.page.waitForTimeout(200); + + // Check if already disabled + const isChecked = await this.encryptionCheckbox.isChecked(); + if (isChecked) { + await checkboxLabel.click(); + await this.page.waitForTimeout(100); + await expect(this.encryptionCheckbox).not.toBeChecked({ timeout: 3000 }); + } + + // Save the configuration + await this.saveBtn.waitFor({ state: 'visible', timeout: 5000 }); + await this.page.waitForTimeout(100); + + await Promise.race([ + this.saveBtn.click({ timeout: 5000 }), + this.page + .locator('mat-dialog-container') + .waitFor({ state: 'detached', timeout: 5000 }), + ]); + + // Wait for clean slate operation to complete + await this.page.waitForTimeout(1000); + } } diff --git a/e2e/tests/sync/supersync-encryption-enable-disable.spec.ts b/e2e/tests/sync/supersync-encryption-enable-disable.spec.ts new file mode 100644 index 0000000000..e40011780c --- /dev/null +++ b/e2e/tests/sync/supersync-encryption-enable-disable.spec.ts @@ -0,0 +1,509 @@ +import { test, expect } from '../../fixtures/supersync.fixture'; +import { + createTestUser, + getSuperSyncConfig, + createSimulatedClient, + closeClient, + waitForTask, + type SimulatedE2EClient, +} from '../../utils/supersync-helpers'; + +/** + * SuperSync Encryption Enable/Disable E2E Tests + * + * These tests verify that enabling/disabling encryption on existing synced data + * properly handles the clean slate mechanism: + * - Server data is wiped (encrypted ops can't mix with unencrypted) + * - A fresh snapshot is uploaded with correct encryption settings + * - Other clients adapt to the new encryption state + * + * Scenarios covered: + * C) Encryption disabled (password removed) - other clients recognize and disable locally + * D) Encryption enabled (password added) - other clients recognize and prompt for password + * + * Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-encryption-enable-disable.spec.ts + */ + +test.describe('@supersync @encryption Encryption Enable/Disable', () => { + /** + * Scenario C: Remove encryption password (disable encryption) + * + * Setup: + * - Client A has encryption enabled with synced encrypted data + * - Client B also has encryption enabled and synced + * + * Actions: + * 1. Client A disables encryption (removes password) + * 2. Clean slate is triggered (server wiped, fresh unencrypted snapshot uploaded) + * 3. Client B syncs and should receive unencrypted data + * 4. Client B should automatically disable encryption settings locally + * + * Verify: + * - Client A's data is now unencrypted on server + * - Client B successfully syncs WITHOUT encryption + * - Both clients have same data + * - No encryption errors occur + */ + test('Disabling encryption triggers clean slate and other clients adapt', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const baseConfig = getSuperSyncConfig(user); + const encryptionPassword = `pass-${testRunId}`; + + // ============ PHASE 1: Setup both clients with encryption ============ + console.log('[DisableEncryption] Phase 1: Setting up clients with encryption'); + + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: encryptionPassword, + }); + + // Create and sync encrypted task + const encryptedTask = `EncryptedTask-${uniqueId}`; + await clientA.workView.addTask(encryptedTask); + await clientA.sync.syncAndWait(); + console.log(`[DisableEncryption] Client A created: ${encryptedTask}`); + + // Setup Client B with same encryption + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: encryptionPassword, + }); + await clientB.sync.syncAndWait(); + + // Verify both clients have the encrypted task + await waitForTask(clientA.page, encryptedTask); + await waitForTask(clientB.page, encryptedTask); + console.log('[DisableEncryption] Both clients synced with encryption'); + + // ============ PHASE 2: Client A disables encryption ============ + console.log('[DisableEncryption] Phase 2: Client A disabling encryption'); + + // Open encryption settings dialog + await clientA.sync.openEncryptionDialog(); + + // Disable encryption (remove password) + await clientA.sync.disableEncryption(); + console.log('[DisableEncryption] Client A disabled encryption'); + + // Verify clean slate was triggered by checking task still exists + await waitForTask(clientA.page, encryptedTask); + console.log('[DisableEncryption] Client A data preserved after clean slate'); + + // ============ PHASE 3: Client A creates new unencrypted task ============ + const unencryptedTask = `UnencryptedTask-${uniqueId}`; + await clientA.workView.addTask(unencryptedTask); + await clientA.sync.syncAndWait(); + console.log(`[DisableEncryption] Client A created unencrypted: ${unencryptedTask}`); + + // ============ PHASE 4: Client B syncs and should receive unencrypted data ============ + console.log( + '[DisableEncryption] Phase 4: Client B syncing after encryption disabled', + ); + + // Client B should sync successfully without encryption errors + await clientB.sync.syncAndWait(); + console.log('[DisableEncryption] Client B synced successfully'); + + // Client B should now have both tasks (clean slate replaced all data) + await waitForTask(clientB.page, encryptedTask); + await waitForTask(clientB.page, unencryptedTask); + + // ============ PHASE 5: Verify encryption is disabled for both clients ============ + console.log('[DisableEncryption] Phase 5: Verifying encryption is disabled'); + + // Verify no sync errors on either client + const hasErrorA = await clientA.sync.hasSyncError(); + const hasErrorB = await clientB.sync.hasSyncError(); + expect(hasErrorA).toBe(false); + expect(hasErrorB).toBe(false); + + // Verify both clients can create and sync new tasks without encryption + const finalTask = `FinalTask-${uniqueId}`; + await clientB.workView.addTask(finalTask); + await clientB.sync.syncAndWait(); + + await clientA.sync.syncAndWait(); + await waitForTask(clientA.page, finalTask); + + console.log( + '[DisableEncryption] ✓ Encryption disabled successfully on both clients!', + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); + + /** + * Scenario D: Add encryption password (enable encryption) + * + * Setup: + * - Client A has unencrypted sync active with data + * - Client B also has unencrypted sync + * + * Actions: + * 1. Client A enables encryption (adds password) + * 2. Clean slate is triggered (server wiped, fresh encrypted snapshot uploaded) + * 3. Client B syncs and receives encrypted data + * 4. Client B should detect encryption and prompt for password + * 5. Client B provides correct password and syncs successfully + * + * Verify: + * - Client A's data is now encrypted on server + * - Client B cannot sync without password + * - Client B can sync after providing correct password + * - Both clients have same encrypted data + */ + test('Enabling encryption triggers clean slate and other clients require password', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const baseConfig = getSuperSyncConfig(user); + const encryptionPassword = `newpass-${testRunId}`; + + // ============ PHASE 1: Setup both clients WITHOUT encryption ============ + console.log('[EnableEncryption] Phase 1: Setting up clients without encryption'); + + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: false, + }); + + // Create and sync unencrypted task + const unencryptedTask = `UnencryptedTask-${uniqueId}`; + await clientA.workView.addTask(unencryptedTask); + await clientA.sync.syncAndWait(); + console.log(`[EnableEncryption] Client A created: ${unencryptedTask}`); + + // Setup Client B without encryption + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: false, + }); + await clientB.sync.syncAndWait(); + + // Verify both clients have the unencrypted task + await waitForTask(clientA.page, unencryptedTask); + await waitForTask(clientB.page, unencryptedTask); + console.log('[EnableEncryption] Both clients synced without encryption'); + + // ============ PHASE 2: Client A enables encryption ============ + console.log('[EnableEncryption] Phase 2: Client A enabling encryption'); + + // Open encryption settings dialog + await clientA.sync.openEncryptionDialog(); + + // Enable encryption with new password + await clientA.sync.enableEncryption(encryptionPassword); + console.log('[EnableEncryption] Client A enabled encryption'); + + // Verify clean slate was triggered by checking task still exists + await waitForTask(clientA.page, unencryptedTask); + console.log('[EnableEncryption] Client A data preserved after clean slate'); + + // ============ PHASE 3: Client A creates new encrypted task ============ + const encryptedTask = `EncryptedTask-${uniqueId}`; + await clientA.workView.addTask(encryptedTask); + await clientA.sync.syncAndWait(); + console.log(`[EnableEncryption] Client A created encrypted: ${encryptedTask}`); + + // ============ PHASE 4: Client B syncs WITHOUT password (should fail) ============ + console.log('[EnableEncryption] Phase 4: Client B syncing without password'); + + // Client B tries to sync - should detect encryption and fail + try { + await clientB.sync.syncAndWait(); + // If we get here, the test should fail because sync should have failed + throw new Error('Client B should not sync without encryption password'); + } catch (error) { + // Expected: sync should fail because data is encrypted + console.log( + '[EnableEncryption] Client B sync failed as expected (needs password)', + ); + console.log(`[EnableEncryption] Error: ${error}`); + } + + // Verify Client B has sync error related to encryption + const hasError = await clientB.sync.hasSyncError(); + expect(hasError).toBe(true); + console.log('[EnableEncryption] ✓ Client B detected encryption requirement'); + + // ============ PHASE 5: Client B provides password and syncs successfully ============ + console.log('[EnableEncryption] Phase 5: Client B providing password'); + + // Reconfigure Client B with encryption password + await clientB.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: encryptionPassword, + }); + console.log('[EnableEncryption] Client B configured with encryption password'); + + // Now sync should work + await clientB.sync.syncAndWait(); + console.log('[EnableEncryption] Client B synced successfully with password'); + + // Client B should now have both tasks + await waitForTask(clientB.page, unencryptedTask); + await waitForTask(clientB.page, encryptedTask); + + // ============ PHASE 6: Verify bidirectional encrypted sync works ============ + console.log('[EnableEncryption] Phase 6: Verifying bidirectional encrypted sync'); + + // Client B creates a task + const finalTask = `FinalTask-${uniqueId}`; + await clientB.workView.addTask(finalTask); + await clientB.sync.syncAndWait(); + + // Client A syncs and should receive it + await clientA.sync.syncAndWait(); + await waitForTask(clientA.page, finalTask); + + // Verify no sync errors + const hasErrorA = await clientA.sync.hasSyncError(); + const hasErrorB = await clientB.sync.hasSyncError(); + expect(hasErrorA).toBe(false); + expect(hasErrorB).toBe(false); + + console.log( + '[EnableEncryption] ✓ Encryption enabled successfully on both clients!', + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); + + /** + * Scenario: Multiple encryption state changes work correctly + * + * Tests that: + * - Client can enable → disable → enable encryption multiple times + * - Each state change triggers clean slate + * - Other clients adapt to each change + */ + test('Multiple encryption state changes work correctly', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const baseConfig = getSuperSyncConfig(user); + const password1 = `pass1-${testRunId}`; + const password2 = `pass2-${testRunId}`; + + // ============ PHASE 1: Start unencrypted ============ + console.log('[MultipleChanges] Phase 1: Starting unencrypted'); + + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: false, + }); + + const task1 = `Task1-${uniqueId}`; + await clientA.workView.addTask(task1); + await clientA.sync.syncAndWait(); + + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: false, + }); + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, task1); + + // ============ PHASE 2: Enable encryption ============ + console.log('[MultipleChanges] Phase 2: Enabling encryption'); + + await clientA.sync.openEncryptionDialog(); + await clientA.sync.enableEncryption(password1); + + const task2 = `Task2-${uniqueId}`; + await clientA.workView.addTask(task2); + await clientA.sync.syncAndWait(); + + // Client B should fail without password + try { + await clientB.sync.syncAndWait(); + throw new Error('Should have failed'); + } catch (error) { + console.log('[MultipleChanges] Client B failed as expected'); + } + + // Client B gets password + await clientB.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: password1, + }); + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, task2); + + // ============ PHASE 3: Disable encryption ============ + console.log('[MultipleChanges] Phase 3: Disabling encryption'); + + await clientA.sync.openEncryptionDialog(); + await clientA.sync.disableEncryption(); + + const task3 = `Task3-${uniqueId}`; + await clientA.workView.addTask(task3); + await clientA.sync.syncAndWait(); + + // Client B should sync successfully (unencrypted now) + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, task3); + + // ============ PHASE 4: Re-enable with different password ============ + console.log('[MultipleChanges] Phase 4: Re-enabling with different password'); + + await clientA.sync.openEncryptionDialog(); + await clientA.sync.enableEncryption(password2); + + const task4 = `Task4-${uniqueId}`; + await clientA.workView.addTask(task4); + await clientA.sync.syncAndWait(); + + // Client B needs NEW password + await clientB.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: password2, + }); + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, task4); + + // ============ PHASE 5: Verify final state ============ + console.log('[MultipleChanges] Phase 5: Verifying final state'); + + // All tasks should be present on both clients + await waitForTask(clientA.page, task1); + await waitForTask(clientA.page, task2); + await waitForTask(clientA.page, task3); + await waitForTask(clientA.page, task4); + + await waitForTask(clientB.page, task1); + await waitForTask(clientB.page, task2); + await waitForTask(clientB.page, task3); + await waitForTask(clientB.page, task4); + + const hasErrorA = await clientA.sync.hasSyncError(); + const hasErrorB = await clientB.sync.hasSyncError(); + expect(hasErrorA).toBe(false); + expect(hasErrorB).toBe(false); + + console.log('[MultipleChanges] ✓ Multiple encryption state changes work!'); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); + + /** + * Scenario: Concurrent changes during encryption state change + * + * Tests that: + * - If Client B makes changes while Client A is changing encryption state + * - Client B's changes are overwritten by clean slate (expected behavior) + * - This is correct: clean slate is an explicit user action to reset sync + */ + test('Concurrent changes are overwritten by encryption state change', async ({ + browser, + baseURL, + testRunId, + }) => { + const uniqueId = Date.now(); + let clientA: SimulatedE2EClient | null = null; + let clientB: SimulatedE2EClient | null = null; + + try { + const user = await createTestUser(testRunId); + const baseConfig = getSuperSyncConfig(user); + const encryptionPassword = `pass-${testRunId}`; + + // Setup both clients unencrypted + clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId); + await clientA.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: false, + }); + + const sharedTask = `SharedTask-${uniqueId}`; + await clientA.workView.addTask(sharedTask); + await clientA.sync.syncAndWait(); + + clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId); + await clientB.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: false, + }); + await clientB.sync.syncAndWait(); + await waitForTask(clientB.page, sharedTask); + + // Client B creates a task (while A will enable encryption) + const concurrentTask = `ConcurrentTask-${uniqueId}`; + await clientB.workView.addTask(concurrentTask); + // Note: B does NOT sync yet + + // Client A enables encryption (triggers clean slate) + await clientA.sync.openEncryptionDialog(); + await clientA.sync.enableEncryption(encryptionPassword); + + const taskAfterEncryption = `AfterEncryption-${uniqueId}`; + await clientA.workView.addTask(taskAfterEncryption); + await clientA.sync.syncAndWait(); + + // Now Client B syncs + await clientB.sync.setupSuperSync({ + ...baseConfig, + isEncryptionEnabled: true, + password: encryptionPassword, + }); + await clientB.sync.syncAndWait(); + + // Client B should have tasks from clean slate (A's state) + await waitForTask(clientB.page, sharedTask); + await waitForTask(clientB.page, taskAfterEncryption); + + // CRITICAL: Client B's concurrent task should be GONE (overwritten by clean slate) + const concurrentTaskLocator = clientB.page.locator( + `task:has-text("${concurrentTask}")`, + ); + await expect(concurrentTaskLocator).not.toBeVisible({ timeout: 5000 }); + + console.log( + '[Concurrent] ✓ Concurrent changes correctly overwritten by clean slate!', + ); + } finally { + if (clientA) await closeClient(clientA); + if (clientB) await closeClient(clientB); + } + }); +});