mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
test(sync): add encryption and supersync e2e test coverage
Add comprehensive test coverage for the encryption and SuperSync features: - Add e2e tests for encryption prompt loop, error scenarios, no-op sync - Add e2e tests for provider switch, re-enable, account reset, server migration - Add e2e tests for import clean-slate scenario - Add unit tests for encryption dialogs, toggle, enable/disable services - Add unit tests for sync-config, sync-wrapper, file-based-encryption - Add unit tests for operation-log-sync, sync-import-filter, clean-slate - Update supersync page object for mandatory encryption flows
This commit is contained in:
parent
b46cce72df
commit
baa9a46935
23 changed files with 2829 additions and 583 deletions
|
|
@ -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<void> => {
|
||||
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<void> => {
|
||||
// Set test environment variables
|
||||
process.env.TZ = 'Europe/Berlin';
|
||||
|
|
@ -30,6 +57,13 @@ const globalSetup = async (config: FullConfig): Promise<void> => {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
162
e2e/tests/sync/supersync-encryption-prompt-loop.spec.ts
Normal file
162
e2e/tests/sync/supersync-encryption-prompt-loop.spec.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
481
e2e/tests/sync/supersync-error-scenarios.spec.ts
Normal file
481
e2e/tests/sync/supersync-error-scenarios.spec.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
81
e2e/tests/sync/supersync-no-op-sync.spec.ts
Normal file
81
e2e/tests/sync/supersync-no-op-sync.spec.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
187
e2e/tests/sync/supersync-provider-switch-to-supersync.spec.ts
Normal file
187
e2e/tests/sync/supersync-provider-switch-to-supersync.spec.ts
Normal file
|
|
@ -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<boolean> => {
|
||||
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<ReturnType<typeof browser.newContext>> | 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
185
e2e/tests/sync/supersync-reenable-and-account-switch.spec.ts
Normal file
185
e2e/tests/sync/supersync-reenable-and-account-switch.spec.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
115
e2e/tests/sync/supersync-server-migration-abort.spec.ts
Normal file
115
e2e/tests/sync/supersync-server-migration-abort.spec.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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<EncryptionPasswordChangeService>;
|
||||
let mockFileBasedEncryptionService: jasmine.SpyObj<FileBasedEncryptionService>;
|
||||
let mockSnackService: jasmine.SpyObj<SnackService>;
|
||||
let mockEncryptionDisableService: jasmine.SpyObj<EncryptionDisableService>;
|
||||
let mockEncryptionToggleService: jasmine.SpyObj<SuperSyncEncryptionToggleService>;
|
||||
let mockSyncWrapperService: jasmine.SpyObj<SyncWrapperService>;
|
||||
|
||||
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 <T>(operation: () => Promise<T>): Promise<T> => 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' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<SnapshotUploadService>;
|
||||
let mockWrappedProviderService: jasmine.SpyObj<WrappedProviderService>;
|
||||
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
|
||||
let mockStateSnapshotService: jasmine.SpyObj<StateSnapshotService>;
|
||||
let mockVectorClockService: jasmine.SpyObj<VectorClockService>;
|
||||
let mockFileBasedAdapter: jasmine.SpyObj<FileBasedSyncAdapterService>;
|
||||
let mockGlobalConfigService: jasmine.SpyObj<GlobalConfigService>;
|
||||
let mockSyncProvider: jasmine.SpyObj<
|
||||
SyncProviderServiceInterface<SyncProviderId> & 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<SyncProviderId>
|
||||
>;
|
||||
let mockAdapter: jasmine.SpyObj<OperationSyncCapable>;
|
||||
|
||||
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: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<SnapshotUploadService>;
|
||||
let mockEncryptionService: jasmine.SpyObj<OperationEncryptionService>;
|
||||
let mockWrappedProviderService: jasmine.SpyObj<WrappedProviderService>;
|
||||
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
|
||||
let mockSyncProvider: jasmine.SpyObj<
|
||||
SyncProviderServiceInterface<SyncProviderId> & 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
479
src/app/imex/sync/supersync-encryption-toggle.service.spec.ts
Normal file
479
src/app/imex/sync/supersync-encryption-toggle.service.spec.ts
Normal file
|
|
@ -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<SnapshotUploadService>;
|
||||
let mockEncryptionService: jasmine.SpyObj<OperationEncryptionService>;
|
||||
let mockWrappedProviderService: jasmine.SpyObj<WrappedProviderService>;
|
||||
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
|
||||
let mockSyncProvider: jasmine.SpyObj<
|
||||
SyncProviderServiceInterface<SyncProviderId> & 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<SyncProviderManager>;
|
||||
let mockSyncConfig$: BehaviorSubject<SyncConfig>;
|
||||
let mockCurrentProviderPrivateCfg$: BehaviorSubject<any>;
|
||||
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<SyncProviderManager>;
|
||||
});
|
||||
|
||||
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>;
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<OperationWriteFlushService>;
|
||||
let superSyncStatusServiceSpy: jasmine.SpyObj<SuperSyncStatusService>;
|
||||
let stateSnapshotServiceSpy: jasmine.SpyObj<StateSnapshotService>;
|
||||
let syncImportConflictDialogServiceSpy: jasmine.SpyObj<SyncImportConflictDialogService>;
|
||||
|
||||
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<OperationLogDownloadService>;
|
||||
|
||||
beforeEach(() => {
|
||||
downloadServiceSpy = TestBed.inject(
|
||||
OperationLogDownloadService,
|
||||
) as jasmine.SpyObj<OperationLogDownloadService>;
|
||||
|
||||
// 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<OperationLogUploadService>;
|
||||
|
||||
beforeEach(() => {
|
||||
uploadServiceSpy = TestBed.inject(
|
||||
OperationLogUploadService,
|
||||
) as jasmine.SpyObj<OperationLogUploadService>;
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue