mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
- 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)
434 lines
16 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|