super-productivity/e2e/tests/sync/supersync-encryption-password-preservation.spec.ts
Johannes Millan cce9576946 test(e2e): fix supersync E2E tests for mandatory encryption flow
- Extract shared encryption warning helper and fix race conditions
- Restructure concurrent-import tests to import before enabling sync
- Fix encryption password preservation and change tests
- Add "Change Password" button detection in supersync page object
- Mark backup-import ID mismatch test as fixme (known limitation)
2026-03-06 16:36:38 +01:00

434 lines
16 KiB
TypeScript

import { test, expect } from '../../fixtures/supersync.fixture';
import {
createTestUser,
getSuperSyncConfig,
createSimulatedClient,
closeClient,
waitForTask,
type SimulatedE2EClient,
} from '../../utils/supersync-helpers';
/**
* SuperSync Encryption Password Preservation E2E Tests
*
* These tests verify the bug fixes for multiple race conditions that could
* cause encryption passwords to be lost. The bugs were:
*
* 1. Form model race condition: Angular's ngModel could overwrite the password
* with a stale value ~2 seconds after enabling encryption
* 2. Observable update timing: currentProviderPrivateCfg$ wasn't updated
* when enabling encryption, causing stale reads
* 3. providerManager.setProviderConfig: File-based providers could lose
* password when config was updated through providerManager
*
* Prerequisites:
* - super-sync-server running on localhost:1901 with TEST_MODE=true
* - Frontend running on localhost:4242
*
* Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-encryption-password-preservation.spec.ts
*/
test.describe('@supersync @encryption Password Preservation', () => {
/**
* Scenario: Encryption password survives form model race condition
*
* Tests that after enabling encryption, the password is not cleared by
* a delayed form model update.
*
* Setup:
* - Client A configures SuperSync without encryption
*
* Actions:
* 1. Client A creates task, syncs
* 2. Client A enables encryption
* 3. Wait 5+ seconds (longer than race condition window)
* 4. Client A creates another task, syncs
* 5. Client B joins with same password
*
* Verify:
* - Client B can decrypt and sees all tasks
* - No decrypt errors
*/
test('Encryption password survives form model race condition', async ({
browser,
baseURL,
testRunId,
}, testInfo) => {
testInfo.setTimeout(180000);
const uniqueId = Date.now();
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
const encryptionPassword = `preserve-test-${testRunId}`;
// ============ PHASE 1: Setup Client A with encryption ============
// SuperSync has mandatory encryption, so we set up directly with the test password.
// The race condition we're testing is whether the password survives after
// Angular form model updates that could overwrite it with stale values.
console.log('[PasswordRace] Phase 1: Setting up Client A with encryption');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: encryptionPassword,
});
// Create initial task
const task1 = `Task1-BeforeEncrypt-${uniqueId}`;
await clientA.workView.addTask(task1);
await clientA.sync.syncAndWait();
console.log(`[PasswordRace] Client A created: ${task1}`);
// ============ PHASE 2: Open and close settings to trigger form model race ============
// Opening the settings dialog triggers Angular form model initialization
// which could overwrite the encryption password with stale values
console.log('[PasswordRace] Phase 2: Opening settings to trigger form model race');
await clientA.sync.syncBtn.click({ button: 'right' });
const dialog = clientA.page.locator('mat-dialog-container');
await dialog.waitFor({ state: 'visible', timeout: 5000 });
await clientA.page.waitForTimeout(1000);
await clientA.page.keyboard.press('Escape');
await dialog.waitFor({ state: 'hidden', timeout: 5000 });
console.log('[PasswordRace] Settings dialog opened and closed');
// ============ PHASE 3: Wait for race condition window ============
console.log('[PasswordRace] Phase 3: Waiting 6 seconds (race condition window)');
// The race condition typically occurred within 2-3 seconds after form model init
// We wait longer to ensure any delayed updates have fired
await clientA.page.waitForTimeout(6000);
console.log('[PasswordRace] Wait complete');
// ============ PHASE 4: Create task and sync after wait ============
console.log('[PasswordRace] Phase 4: Creating task after wait');
const task2 = `Task2-AfterWait-${uniqueId}`;
await clientA.workView.addTask(task2);
// This sync would fail if password was cleared by race condition
await clientA.sync.syncAndWait();
console.log(`[PasswordRace] Client A synced task: ${task2}`);
// Verify no sync errors on A
const hasErrorA = await clientA.sync.hasSyncError();
expect(hasErrorA).toBe(false);
console.log('[PasswordRace] ✓ Client A synced successfully after wait');
// ============ PHASE 5: Client B joins with password ============
console.log('[PasswordRace] Phase 5: Client B joining with password');
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: encryptionPassword,
});
// Verify B can see both tasks (proves encryption/decryption working)
await waitForTask(clientB.page, task1);
await waitForTask(clientB.page, task2);
const hasErrorB = await clientB.sync.hasSyncError();
expect(hasErrorB).toBe(false);
console.log('[PasswordRace] ✓ Client B decrypted all data successfully');
console.log('[PasswordRace] ✓ Password race condition test PASSED!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario: Password persists after opening settings without changes
*
* Tests that opening the settings dialog (which triggers form model init)
* doesn't overwrite the encryption password with stale/empty values.
*
* Setup:
* - Client A with encryption enabled and synced data
*
* Actions:
* 1. Client A opens settings dialog (right-click sync button)
* 2. Client A closes settings without making changes
* 3. Wait 3 seconds
* 4. Client A creates new task and syncs
*
* Verify:
* - Sync completes without password errors
* - Client B can join and decrypt data
*/
test('Password persists after opening settings without changes', async ({
browser,
baseURL,
testRunId,
}, testInfo) => {
testInfo.setTimeout(180000);
const uniqueId = Date.now();
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
const encryptionPassword = `settings-test-${testRunId}`;
// ============ PHASE 1: Setup Client A with encryption ============
console.log('[SettingsOpen] Phase 1: Setting up Client A with encryption');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: encryptionPassword,
});
// Create initial task
const task1 = `Task1-Initial-${uniqueId}`;
await clientA.workView.addTask(task1);
await clientA.sync.syncAndWait();
console.log(`[SettingsOpen] Client A created and synced: ${task1}`);
// ============ PHASE 2: Open and close settings dialog ============
console.log('[SettingsOpen] Phase 2: Opening settings dialog');
// Right-click sync button to open settings
await clientA.sync.syncBtn.click({ button: 'right' });
// Wait for dialog to appear
const dialog = clientA.page.locator('mat-dialog-container');
await expect(dialog).toBeVisible({ timeout: 5000 });
console.log('[SettingsOpen] Settings dialog opened');
// Wait a moment to let form initialize
await clientA.page.waitForTimeout(1000);
// Close dialog without making changes (press Escape)
await clientA.page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible({ timeout: 5000 });
console.log('[SettingsOpen] Settings dialog closed');
// ============ PHASE 3: Wait and create new task ============
console.log('[SettingsOpen] Phase 3: Waiting after settings close');
// Wait for any delayed form model updates
await clientA.page.waitForTimeout(3000);
const task2 = `Task2-AfterSettings-${uniqueId}`;
await clientA.workView.addTask(task2);
// This sync would fail if password was cleared
await clientA.sync.syncAndWait();
console.log(`[SettingsOpen] Client A synced: ${task2}`);
// Verify no sync errors
const hasErrorA = await clientA.sync.hasSyncError();
expect(hasErrorA).toBe(false);
console.log('[SettingsOpen] ✓ Sync successful after settings dialog');
// ============ PHASE 4: Verify with Client B ============
console.log('[SettingsOpen] Phase 4: Verifying with Client B');
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: encryptionPassword,
});
await waitForTask(clientB.page, task1);
await waitForTask(clientB.page, task2);
const hasErrorB = await clientB.sync.hasSyncError();
expect(hasErrorB).toBe(false);
console.log('[SettingsOpen] ✓ Client B decrypted all data');
console.log('[SettingsOpen] ✓ Settings open test PASSED!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario: Password preserved after multiple sync cycles
*
* Tests that the encryption password remains functional after multiple
* sync operations (tests observable and state persistence).
*
* Setup:
* - Client A with encryption enabled
*
* Actions:
* 1. Client A performs 5 sync cycles, creating a task each time
*
* Verify:
* - All syncs complete without errors
* - Client B can join and see all tasks
*/
test('Password preserved after multiple sync cycles', async ({
browser,
baseURL,
testRunId,
}, testInfo) => {
testInfo.setTimeout(180000);
const uniqueId = Date.now();
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
const encryptionPassword = `multi-cycle-${testRunId}`;
// ============ PHASE 1: Setup Client A with encryption ============
console.log('[MultiCycle] Phase 1: Setting up Client A');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: encryptionPassword,
});
// ============ PHASE 2: Multiple sync cycles ============
console.log('[MultiCycle] Phase 2: Performing 5 sync cycles');
const taskNames: string[] = [];
for (let i = 1; i <= 5; i++) {
const taskName = `MultiTask${i}-${uniqueId}`;
taskNames.push(taskName);
await clientA.workView.addTask(taskName);
await clientA.sync.syncAndWait();
// Verify no error after each sync
const hasError = await clientA.sync.hasSyncError();
expect(hasError).toBe(false);
console.log(`[MultiCycle] Cycle ${i}/5 complete: ${taskName}`);
}
// ============ PHASE 3: Verify with Client B ============
console.log('[MultiCycle] Phase 3: Verifying with Client B');
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: encryptionPassword,
});
// Verify all tasks present
for (const taskName of taskNames) {
await waitForTask(clientB.page, taskName);
}
const hasErrorB = await clientB.sync.hasSyncError();
expect(hasErrorB).toBe(false);
console.log('[MultiCycle] ✓ All 5 tasks received by Client B');
console.log('[MultiCycle] ✓ Multi-cycle test PASSED!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
/**
* Scenario: Password change migration works correctly
*
* Tests that changing the encryption password properly re-encrypts data
* and both old and new clients work correctly.
*
* Setup:
* - Client A with encryption (password1)
*
* Actions:
* 1. Client A creates task, syncs
* 2. Client A changes password to password2
* 3. Client A creates another task, syncs
* 4. Client B joins with new password (password2)
*
* Verify:
* - Client B can decrypt and see all tasks
*/
test('Password change migration works correctly', async ({
browser,
baseURL,
testRunId,
}, testInfo) => {
testInfo.setTimeout(180000);
const uniqueId = Date.now();
let clientA: SimulatedE2EClient | null = null;
let clientB: SimulatedE2EClient | null = null;
try {
const user = await createTestUser(testRunId);
const syncConfig = getSuperSyncConfig(user);
const password1 = `old-pass-${testRunId}`;
const password2 = `new-pass-${testRunId}`;
// ============ PHASE 1: Setup with initial password ============
console.log('[PasswordChange] Phase 1: Setting up with initial password');
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
await clientA.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: password1,
});
const task1 = `Task1-OldPass-${uniqueId}`;
await clientA.workView.addTask(task1);
await clientA.sync.syncAndWait();
console.log(`[PasswordChange] Created with old password: ${task1}`);
// ============ PHASE 2: Change password ============
console.log('[PasswordChange] Phase 2: Changing password');
await clientA.sync.changeEncryptionPassword(password2);
console.log('[PasswordChange] Password changed');
// ============ PHASE 3: Create task with new password ============
console.log('[PasswordChange] Phase 3: Creating task with new password');
const task2 = `Task2-NewPass-${uniqueId}`;
await clientA.workView.addTask(task2);
await clientA.sync.syncAndWait();
console.log(`[PasswordChange] Created with new password: ${task2}`);
// Verify no sync errors
const hasErrorA = await clientA.sync.hasSyncError();
expect(hasErrorA).toBe(false);
// ============ PHASE 4: Client B joins with new password ============
console.log('[PasswordChange] Phase 4: Client B joining with new password');
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
await clientB.sync.setupSuperSync({
...syncConfig,
isEncryptionEnabled: true,
password: password2,
});
// Verify B can see both tasks
await waitForTask(clientB.page, task1);
await waitForTask(clientB.page, task2);
const hasErrorB = await clientB.sync.hasSyncError();
expect(hasErrorB).toBe(false);
console.log('[PasswordChange] ✓ Client B decrypted all data with new password');
console.log('[PasswordChange] ✓ Password change test PASSED!');
} finally {
if (clientA) await closeClient(clientA);
if (clientB) await closeClient(clientB);
}
});
});